diff --git a/.agents/skills/fix-security-vulnerability/scripts/classify-alerts.mjs b/.agents/skills/fix-security-vulnerability/scripts/classify-alerts.mjs index 9d516625709e..03723204cb62 100644 --- a/.agents/skills/fix-security-vulnerability/scripts/classify-alerts.mjs +++ b/.agents/skills/fix-security-vulnerability/scripts/classify-alerts.mjs @@ -69,7 +69,7 @@ function fetchOpenAlerts() { } // Resolve a dependency's installed directory, preferring the consumer's nested node_modules and -// falling back to the hoisted root node_modules (`node-linker=hoisted` in .npmrc hoists most deps to the root). +// falling back to the hoisted root node_modules (`nodeLinker: hoisted` in pnpm-workspace.yaml hoists most deps to the root). function resolveDepDir(name, baseDir) { const candidates = [join(baseDir, 'node_modules', name), join(REPO_ROOT, 'node_modules', name)]; return candidates.find(dir => existsSync(join(dir, 'package.json'))); diff --git a/.claude/CLOUD.md b/.claude/CLOUD.md index 3a7f737b2337..ce4c2c407079 100644 --- a/.claude/CLOUD.md +++ b/.claude/CLOUD.md @@ -23,5 +23,5 @@ These cannot live in the repo and must be set once per environment: ## Notes -- Node/pnpm versions come from `package.json` (Volta locally). In the cloud we use the sandbox Node runtime, and the setup script installs the pinned pnpm version when the sandbox has none. +- Node.js/pnpm versions come from `package.json` (`devEngines.runtime` and `packageManager`, managed by pnpm locally). In the cloud we use the sandbox Node.js runtime, and the setup script installs the pinned pnpm version when the sandbox has none. - The full production `pnpm run build` is not run at startup; `build:dev` (transpile + types) is enough for editing and running unit tests. Run `pnpm run build` manually if you need bundles. diff --git a/.claude/settings.json b/.claude/settings.json index 1a9bbaa57b7c..fe8cbb078720 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -15,37 +15,5 @@ ] } ] - }, - "permissions": { - "allow": [ - "Bash(find:*)", - "Bash(ls:*)", - "Bash(git status:*)", - "Bash(git log:*)", - "Bash(git diff:*)", - "Bash(git show:*)", - "Bash(git branch:*)", - "Bash(git remote:*)", - "Bash(git tag:*)", - "Bash(git stash list:*)", - "Bash(git rev-parse:*)", - "Bash(gh pr view:*)", - "Bash(gh pr list:*)", - "Bash(gh pr checks:*)", - "Bash(gh pr diff:*)", - "Bash(gh issue view:*)", - "Bash(gh issue list:*)", - "Bash(gh run view:*)", - "Bash(gh run list:*)", - "Bash(gh run logs:*)", - "Bash(gh repo view:*)", - "Bash(pnpm:*)", - "WebFetch(domain:github.com)", - "WebFetch(domain:docs.sentry.io)", - "WebFetch(domain:develop.sentry.dev)", - "Bash(grep:*)", - "Bash(mv:*)" - ], - "deny": [] } } diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index 8212b1b8489e..7091a161f177 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -49,6 +49,9 @@ Keep reviews high-signal. Prefer actionable, high-confidence findings over specu - When calling any `startSpan` API (`startInactiveSpan`, `startSpanManual`, etc), always ensure that the following span attributes are set: - `SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN` (`'sentry.origin'`) with a proper span origin - a proper origin must only contain [a-z], [A-Z], [0-9], `_` and `.` characters. + - a proper origin has at most four parts, `...`, and must not skip a part. `type` is `auto` or `manual`. + - `category` must be a span op category (e.g. `http`, `db`, `queue`, `function`, `ai`, `ui`, `rpc`), matching the span's op. Flag origins that put a library or vendor in the category slot (e.g. `auto.kafkajs.producer` instead of `auto.queue.kafkajs.producer`). + - the origin identifies the integration, not the tooling behind it. Flag leftover implementation markers like `otel` or `orchestrion` used as a part. - flag any non-conforming origin values as invalid and link to the trace origin specification (https://develop.sentry.dev/sdk/telemetry/traces/trace-origin/) - `SEMANTIC_ATTRIBUTE_SENTRY_OP` (`'sentry.op'`) with a proper span op - Span ops should be lower case only, and use snake_case. The `.` character is used to delimit op parts. @@ -78,6 +81,7 @@ Keep reviews high-signal. Prefer actionable, high-confidence findings over specu - What to do instead: Use an existing reference to the scope or client. For example, this is possible in most `Integration` hooks. - Flag hard-coded attribute names and span ops (e.g. `'sentry.origin'`, `'sentry.op'`, `'http.request.method'`, `'db.system.name'`) when `@sentry/conventions` exports a constant for them. Use the constant from `@sentry/conventions/attributes` (e.g. `SENTRY_ORIGIN`, `SENTRY_OP`) or `@sentry/conventions/op` instead. Do not flag values that have no constant in `@sentry/conventions`. - Flag unnecessary `span.setAttribute(s)` calls: If data is already available at span start, it must be set via the `attributes` option of `startSpan`, `startSpanManual`, `startInactiveSpan` or `startIdleSpan` calls. This ensures that as much context as possible is available when `tracesSampler` or `ignoreSpans` SDK options are applied. If a `span.setAttribute(s)` call happens at a later time than right after span start and the attribute value can only be computed at that time, do not flag it. +- Flag a new option on an integration for incoming HTTP requests that is not in `ServerRequestOptions` (`packages/core/src/integrations/http/server-request-options.ts`), when it applies to more than one runtime. These integrations are Node `httpIntegration`, Deno `denoHttpIntegration` and `denoServeIntegration`, Bun `bunHttpServerIntegration` and `bunServerIntegration`, and Cloudflare `httpServerIntegration`. Add the option to `ServerRequestOptions`, then support it in each runtime or list it as unsupported in that runtime's `CheckServerRequestOptions` check. Also flag a runtime option that matches a shared option in purpose but has a different name or meaning. ### Code quality diff --git a/.cursor/environment.json b/.cursor/environment.json index a3d966d9a041..a427c1310f25 100644 --- a/.cursor/environment.json +++ b/.cursor/environment.json @@ -1,12 +1,12 @@ { "name": "Sentry JavaScript SDK Development", - "install": "curl https://get.volta.sh | bash && export VOLTA_HOME=\"$HOME/.volta\" && export PATH=\"$VOLTA_HOME/bin:$PATH\" && export VOLTA_FEATURE_PNPM=1 && pnpm install", - "start": "export VOLTA_HOME=\"$HOME/.volta\" && export PATH=\"$VOLTA_HOME/bin:$PATH\" && export VOLTA_FEATURE_PNPM=1", + "install": "curl -fsSL https://get.pnpm.io/install.sh | sh - && export PNPM_HOME=\"$HOME/.local/share/pnpm\" && export PATH=\"$PNPM_HOME/bin:$PATH\" && pnpm runtime set node 24 -g -y && pnpm install", + "start": "export PNPM_HOME=\"$HOME/.local/share/pnpm\" && export PATH=\"$PNPM_HOME/bin:$PATH\"", "terminals": [ { "name": "Development", - "command": "export VOLTA_HOME=\"$HOME/.volta\" && export PATH=\"$VOLTA_HOME/bin:$PATH\" && export VOLTA_FEATURE_PNPM=1 && echo 'Volta setup complete. Node version:' && node --version && echo 'pnpm version:' && pnpm --version", - "description": "Main development terminal with Volta environment configured" + "command": "export PNPM_HOME=\"$HOME/.local/share/pnpm\" && export PATH=\"$PNPM_HOME/bin:$PATH\" && echo 'pnpm setup complete. Node version:' && node --version && echo 'pnpm version:' && pnpm --version", + "description": "Main development terminal with pnpm-managed node" } ] } diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a6dc12f405db..148f6bbb4695 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,6 @@ +# Catch-all rule for shared packages +* @getsentry/team-javascript-sdks-browser @getsentry/team-javascript-sdks-server @getsentry/team-javascript-sdks-framework + # Browser, replay, feedback, and related packages /packages/browser/ @getsentry/team-javascript-sdks-browser /packages/browser-utils/ @getsentry/team-javascript-sdks-browser diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index fd24adc2207a..d12cda804156 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,8 +1,23 @@ -Before submitting a pull request, please take a look at our -[Contributing](https://github.com/getsentry/sentry-javascript/blob/master/CONTRIBUTING.md) guidelines and verify: + + +**Why:** + +**How to review:** + +**Unsure about:** + +Closes # + + diff --git a/.github/actions/install-dependencies/action.yml b/.github/actions/install-dependencies/action.yml index 27971a484f63..2a4b331fd3f6 100644 --- a/.github/actions/install-dependencies/action.yml +++ b/.github/actions/install-dependencies/action.yml @@ -11,6 +11,7 @@ runs: echo 'paths<> "$GITHUB_OUTPUT" shell: bash diff --git a/.github/actions/install-playwright/action.yml b/.github/actions/install-playwright/action.yml index bd84a13e9107..378e77666093 100644 --- a/.github/actions/install-playwright/action.yml +++ b/.github/actions/install-playwright/action.yml @@ -34,6 +34,13 @@ runs: playwright apt + # `--with-deps` names packages that the runner image already has (glib, mesa, nss, ...), and `apt-get install` would + # upgrade them to the latest Ubuntu update. That is half of the download on a cold cache, and the image versions work. + - name: Keep installed apt packages + if: steps.playwright-version.outputs.version != '' + run: echo 'APT::Get::Upgrade "false";' | sudo tee /etc/apt/apt.conf.d/99-no-upgrade > /dev/null + shell: bash + # 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 diff --git a/.github/actions/prepare-e2e-test/action.yml b/.github/actions/prepare-e2e-test/action.yml index 3f5b37b41506..4a4c8a012026 100644 --- a/.github/actions/prepare-e2e-test/action.yml +++ b/.github/actions/prepare-e2e-test/action.yml @@ -22,10 +22,12 @@ inputs: runs: using: 'composite' steps: + # Uses the repo's root node pin for the steps before the app is copied (installing pnpm, `test:prepare`, + # `test:validate`, `ci:copy-to-temp`). The app's own node version is set up after the copy below. - name: Set up Node uses: actions/setup-node@v7 with: - node-version-file: 'dev-packages/e2e-tests/test-applications/${{ inputs.test-application }}/package.json' + node-version-file: 'package.json' - name: Set up Bun if: inputs.test-application == 'node-exports-test-app' || contains(inputs.test-application, 'bun') || inputs.runtime @@ -102,6 +104,14 @@ runs: shell: bash working-directory: dev-packages/e2e-tests + # Reads the node version that `ci:copy-to-temp` wrote into the copied app's `devEngines.runtime`: the app's own pin + # if it has one, otherwise the one inherited from the repo root. setup-node can't look up parent directories, so the + # temp copy has to carry the resolved version. + - name: Set up Node for the E2E app + uses: actions/setup-node@v7 + with: + node-version-file: '${{ runner.temp }}/test-application/package.json' + - name: Add pnpm overrides run: pnpm run ci:pnpm-overrides ${{ runner.temp }}/test-application ${{ github.workspace diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e6692c4ada1d..f6d386c40b43 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,7 +10,7 @@ on: - release/** pull_request: merge_group: - types: [checks_requested] + branches: [develop] workflow_dispatch: inputs: commit: @@ -343,6 +343,14 @@ jobs: run: pnpm run lint:es-compatibility - name: Lint types run: pnpm run lint:types + - name: Check for circular dependencies + run: pnpm run circularDepCheck + - 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 + echo "Found illegal TypeScript import statement." + exit 1 + fi - name: Check Playwright versions run: | version=$(node -p "require('./dev-packages/test-utils/package.json').devDependencies['@playwright/test']") @@ -354,7 +362,8 @@ jobs: job_check_lockfile: name: Check lockfile needs: [job_get_metadata] - timeout-minutes: 10 + # `pnpm dedupe --check` fetches metadata for every package from npm, so this job can take 8+ min on PRs. + timeout-minutes: 20 runs-on: namespace-profile-sentry-javascript if: | needs.job_get_metadata.outputs.changed_deps == 'true' || @@ -406,27 +415,6 @@ jobs: - name: Check file formatting run: pnpm run format:check - job_circular_dep_check: - name: Circular Dependency Check - needs: [job_get_metadata, job_build] - timeout-minutes: 10 - 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 - with: - ref: ${{ env.HEAD_COMMIT }} - - name: Set up Node - uses: actions/setup-node@v7 - with: - node-version-file: 'package.json' - - name: Restore caches - uses: ./.github/actions/restore-cache - - 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] @@ -774,35 +762,8 @@ jobs: overwrite: true retention-days: 7 - job_check_for_faulty_dts: - name: Check for faulty .d.ts files - needs: [job_get_metadata, job_build] - 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 }}) - uses: actions/checkout@v7 - with: - ref: ${{ env.HEAD_COMMIT }} - - name: Set up Node - uses: actions/setup-node@v7 - with: - node-version-file: 'package.json' - - name: Restore caches - uses: ./.github/actions/restore-cache - - 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 - echo "Found illegal TypeScript import statement." - exit 1 - fi - job_node_integration_tests: - name: - Node (${{ matrix.node }})${{ (matrix.typescript && format(' (TS {0})', matrix.typescript)) || '' }} Integration - Tests (${{ matrix.shard }}/3) + name: Node (${{ matrix.node }}) Integration 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: @@ -814,19 +775,6 @@ jobs: matrix: node: [20.19, 22, 24, 26] shard: [1, 2, 3] - typescript: - - false - include: - # Only check typescript for latest version (to streamline CI) - - node: 24 - typescript: '5.0' - shard: 1 - - node: 24 - typescript: '5.0' - shard: 2 - - node: 24 - typescript: '5.0' - shard: 3 steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v7 @@ -852,11 +800,6 @@ jobs: - name: Restore caches uses: ./.github/actions/restore-cache - - name: Overwrite typescript version - if: matrix.typescript == '5.0' - run: node ./scripts/use-ts-5_0.js - working-directory: dev-packages/node-integration-tests - - name: Run integration tests working-directory: dev-packages/node-integration-tests run: pnpm run test --shard=${{ matrix.shard }}/3 @@ -1085,9 +1028,10 @@ 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] + # Most apps finish in under a minute on 4 vCPUs, so they run on 2. Apps that take longer set `sentryTest.runner`. runs-on: - namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript-e2e${{ github.event_name == - 'pull_request' && '-pr' || '' }} + ${{ matrix.runner || 'namespace-profile-sentry-javascript-small' }};overrides.cache-tag=sentry-javascript-e2e${{ + github.event_name == 'pull_request' && '-pr' || '' }} timeout-minutes: 15 env: # We just use a dummy DSN here, only send to the tunnel anyhow @@ -1117,9 +1061,10 @@ jobs: runtime: ${{ matrix.runtime }} deno-version: ${{ matrix.deno-version }} - # 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. + # Installs Playwright while the app installs and builds. Keep it right before the install: 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 @@ -1128,6 +1073,23 @@ jobs: browsers: chromium cwd: dev-packages/e2e-tests/test-applications/${{ matrix.test-application }} + # Every build command runs `pnpm install`, so installing first makes that a no-op. Some downloads from the npm + # registry crawl at tens of kB/s for the whole 7 minutes of the build step, and pnpm does not give up on a slow + # download. Another attempt reuses what is already in the store and opens new connections. + # The hoisted variant installs with another node linker, so installing first would only redo the install. + - name: Install E2E app dependencies + if: ${{ !contains(matrix.build-command, 'hoisted') }} + working-directory: ${{ runner.temp }}/test-application + timeout-minutes: 7 + run: | + for attempt in 1 2; do + timeout 150 pnpm install && exit 0 + echo "::warning::pnpm install attempt $attempt failed or did not finish in 150 s, retrying" + done + pnpm install + env: + SENTRY_E2E_WORKSPACE_ROOT: ${{ github.workspace }} + - name: Build E2E app working-directory: ${{ runner.temp }}/test-application timeout-minutes: 7 @@ -1155,7 +1117,7 @@ jobs: # - We skip optional tests on release branches # - The tests run in optional-e2e.yml, so their failures do not mark the PR as failed. This job only waits for - # them and reports failures in a PR comment. + # them. optional-e2e.yml reports the results in a PR comment. job_optional_e2e_tests: name: E2E Tests (optional) # We only run E2E tests for non-fork PRs because the E2E tests require secrets to work and they can't be accessed from forks @@ -1173,7 +1135,6 @@ jobs: permissions: contents: read actions: write - pull-requests: write steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v7 @@ -1219,7 +1180,6 @@ jobs: job_lint, job_check_lockfile, job_check_format, - job_circular_dep_check, job_size_check, ] # Always run this, even if a dependent job failed diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 45d9191abae5..55a6d1f10bef 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -122,10 +122,12 @@ jobs: ref: ${{ env.HEAD_COMMIT }} - name: Install pnpm uses: ./.github/actions/install-pnpm + # Uses the repo's root node pin for the steps before the app is copied (`test:prepare`, `test:validate`, + # `ci:copy-to-temp`). The app's own node version is set up after the copy below. - name: Set up Node uses: actions/setup-node@v7 with: - node-version-file: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' + node-version-file: 'package.json' - name: Restore canary cache uses: actions/cache/restore@v6.1.0 @@ -147,6 +149,14 @@ jobs: }}/test-application working-directory: dev-packages/e2e-tests + # Reads the node version that `ci:copy-to-temp` wrote into the copied app's `devEngines.runtime`: the app's own pin + # if it has one, otherwise the one inherited from the repo root. setup-node can't look up parent directories, so + # the temp copy has to carry the resolved version. + - name: Set up Node for the E2E app + uses: actions/setup-node@v7 + with: + node-version-file: '${{ runner.temp }}/test-application/package.json' + - name: Add pnpm overrides run: pnpm run ci:pnpm-overrides ${{ runner.temp }}/test-application ${{ github.workspace diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index e748668b92db..997cfc71ff30 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -16,6 +16,14 @@ on: - v10 - v9 - v8 + merge_group: + branches: [develop] + +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true jobs: enforce-license-compliance: diff --git a/.github/workflows/flaky-test-detector.yml b/.github/workflows/flaky-test-detector.yml deleted file mode 100644 index fbde86cf1a0f..000000000000 --- a/.github/workflows/flaky-test-detector.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: 'CI: Detect flaky tests' -on: - workflow_dispatch: - pull_request: - paths: - - 'dev-packages/browser-integration-tests/suites/**/test.ts' - branches-ignore: - - master - -env: - HEAD_COMMIT: ${{ github.event.inputs.commit || github.sha }} - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - flaky-detector: - runs-on: namespace-profile-sentry-javascript-large - timeout-minutes: 60 - name: 'Check tests for flakiness' - # Also skip if PR is from master -> develop - if: ${{ github.base_ref != 'master' && github.ref != 'refs/heads/master' }} - steps: - - name: Check out current branch - uses: actions/checkout@v7 - - name: Install pnpm - uses: ./.github/actions/install-pnpm - - name: Set up Node - uses: actions/setup-node@v7 - with: - node-version-file: 'package.json' - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build packages - run: pnpm run build - - - name: Install Playwright - uses: ./.github/actions/install-playwright - with: - browsers: 'chromium' - cwd: dev-packages/browser-integration-tests - - - name: Determine changed tests - uses: dorny/paths-filter@v4.0.3 - id: changed - with: - list-files: json - filters: | - browser_integration: dev-packages/browser-integration-tests/suites/**/test.ts - - - name: Detect flaky tests - id: test - run: pnpm run test:detect-flaky - working-directory: dev-packages/browser-integration-tests - env: - CHANGED_TEST_PATHS: ${{ steps.changed.outputs.browser_integration_files }} - TEST_RUN_COUNT: 'AUTO' - - - name: Upload Playwright Traces - uses: actions/upload-artifact@v7 - if: failure() && steps.test.outcome == 'failure' - with: - name: playwright-test-results - path: dev-packages/browser-integration-tests/test-results - retention-days: 5 diff --git a/.github/workflows/optional-e2e.yml b/.github/workflows/optional-e2e.yml index 8374ea398ec0..f73eaa256fc0 100644 --- a/.github/workflows/optional-e2e.yml +++ b/.github/workflows/optional-e2e.yml @@ -5,9 +5,6 @@ run-name: Optional E2E tests for ${{ inputs.commit }} (${{ inputs.correlation_id # does not count failed checks of a dispatched run in the PR status, so these failures do not mark # the PR as failed. on: - # Only here so that GitHub registers this workflow before it is on the default branch, which a - # dispatch needs. The job is skipped for this event. Remove this trigger after the merge. - pull_request: workflow_dispatch: inputs: commit: @@ -40,7 +37,6 @@ env: jobs: job_optional_e2e_tests: name: E2E ${{ matrix.label || matrix.test-application }} Test (optional) - if: github.event_name == 'workflow_dispatch' # Same cache volume as the E2E jobs in build.yml. The event is always `workflow_dispatch`, so the PR number # decides if the run gets the PR volume. runs-on: @@ -76,8 +72,7 @@ jobs: pnpm_config_verify_deps_before_run: 'false' strategy: fail-fast: false - # The fallback only keeps the expression valid for the skipped `pull_request` run. - matrix: ${{ fromJson(inputs.matrix || '{"include":[{}]}') }} + matrix: ${{ fromJson(inputs.matrix) }} steps: - name: Check out commit ${{ env.HEAD_COMMIT }} @@ -115,3 +110,36 @@ jobs: working-directory: ${{ runner.temp }}/test-application timeout-minutes: 10 run: ${{ matrix.assert-command || 'pnpm test:assert' }} + + # A separate job in this workflow, so that a re-run of this run (all jobs or only the failed ones) also updates the + # comment. GitHub re-runs the jobs that depend on a re-run job. + job_report: + name: Report in PR comment + needs: [job_optional_e2e_tests] + # Not for a cancelled run: a new push cancels the old run, and the new run reports. + if: ${{ !cancelled() && inputs.pr_number != '' }} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + actions: read + pull-requests: write + steps: + - name: Check out commit ${{ env.HEAD_COMMIT }} + uses: actions/checkout@v7 + with: + ref: ${{ env.HEAD_COMMIT }} + - name: Set up Node + uses: actions/setup-node@v7 + with: + node-version-file: 'package.json' + - name: Install pnpm + uses: ./.github/actions/install-pnpm + - name: Install action dependencies + run: pnpm install --frozen-lockfile --filter @sentry-internal/optional-e2e-gh-action + - name: Report optional E2E results + uses: ./dev-packages/optional-e2e-gh-action + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + mode: report + pr_number: ${{ inputs.pr_number }} diff --git a/.github/workflows/pr-review-reminder.yml b/.github/workflows/pr-review-reminder.yml deleted file mode 100644 index e601f859d2c4..000000000000 --- a/.github/workflows/pr-review-reminder.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: 'PR: Review Reminder' - -on: - workflow_dispatch: - schedule: - # Run on weekdays at 10:00 AM UTC. No new reminders can fire on weekends because - # Saturday/Sunday are never counted as business days. - - cron: '0 10 * * 1-5' - -# pulls.* list + listRequestedReviewers + createComment on PRs → pull-requests: write -# repos.listCollaborators (outside) → Metadata read on the token (see GitHub App permission map) -# checkout → contents: read -permissions: - contents: read - pull-requests: write - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - -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: namespace-profile-sentry-javascript-small - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Remind pending reviewers - uses: actions/github-script@v9 - with: - script: | - const { default: run } = await import( - `${process.env.GITHUB_WORKSPACE}/scripts/pr-review-reminder.mjs` - ); - await run({ github, context, core }); diff --git a/.github/workflows/weekly-flaky-tests.yml b/.github/workflows/weekly-flaky-tests.yml index bb0e5b3866c9..23d0f340b174 100644 --- a/.github/workflows/weekly-flaky-tests.yml +++ b/.github/workflows/weekly-flaky-tests.yml @@ -33,3 +33,9 @@ jobs: `${process.env.GITHUB_WORKSPACE}/scripts/weekly-flaky-tests.mjs` ); await run({ github, context, core }); + - name: Upload the report + uses: actions/upload-artifact@v7 + with: + name: flaky-tests-report + path: flaky-tests-report/ + retention-days: 15 diff --git a/.npmrc b/.npmrc deleted file mode 100644 index d2209a0ecc1b..000000000000 --- a/.npmrc +++ /dev/null @@ -1,3 +0,0 @@ -# Volta runs pnpm 9.15.9, because it cannot run newer versions. This setting makes pnpm 9 switch to the -# `packageManager` version of package.json. The pnpm settings are in pnpm-workspace.yaml. -manage-package-manager-versions=true diff --git a/AGENTS.md b/AGENTS.md index 1539af03e28e..28fcc07f144a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,10 +4,9 @@ Monorepo with 40+ packages in `@sentry/*`, managed with pnpm workspaces and Nx. ## Setup -- [Volta](https://volta.sh/) for Node.js/pnpm version management -- Requires `VOLTA_FEATURE_PNPM=1` +- pnpm manages Node.js and pnpm versions (see `CONTRIBUTING.md`): Node.js in `devEngines.runtime`, pnpm in `packageManager` - After cloning: `pnpm install && pnpm run build` -- Never change Volta, pnpm, or package manager versions unless explicitly asked +- Never change Node.js, pnpm, or package manager versions unless explicitly asked ### Code Intelligence @@ -60,10 +59,9 @@ Uses **Git Flow** (see `docs/gitflow.md`). ## Pull Requests -- **Do NOT add a "Test plan" / "Testing" checklist to PR bodies.** CI runs the full test suite on every PR — a hand-rolled checklist duplicates that signal and rots fast. Write the summary content directly and add a _Root cause_ section only if relevant. -- **Omit the "Summary" heading** in PR bodies — lead with the summary text itself, no `## Summary` header. -- Include `Fixes #` somewhere in the PR body so the merge auto-closes the linked issue. +- Use `.github/PULL_REQUEST_TEMPLATE.md` for the body (`gh pr create --body` skips it). Ideally 1-2 sentences per field, no AI walkthrough. - Always open PRs as draft. +- Never apply the `Accept Bundlesize Increase` label. - **Keep PR descriptions condensed** — usually a few sentences. Explain the _reasoning_: why the change is needed, and any non-obvious decisions or tradeoffs made along the way. Do **not** walk through the implementation or describe what changed file-by-file — the diff already shows that. Only go longer when the decisions genuinely warrant it. ## Architecture @@ -79,6 +77,12 @@ Uses **Git Flow** (see `docs/gitflow.md`). - `Sentry.init()` follows one rule for repeated calls: the first call wins. See `docs/repeated-init.md` before you add or change an `init()`. +- Span origins must follow the [trace origin spec](https://develop.sentry.dev/sdk/telemetry/traces/trace-origin/): + `...`, at most four parts, no skipped + parts, only `[a-zA-Z0-9_]` within a part. `category` is the span op category + (`auto.queue.kafkajs.producer`, not `auto.kafkajs.producer`). Don't encode tooling like + `otel` or `orchestrion` in the origin. + ## Linting & Formatting - This project uses **Oxlint** and **Oxfmt** — NOT ESLint or Prettier diff --git a/CHANGELOG.md b/CHANGELOG.md index b822c9c8fa55..1a0d0150cd82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,217 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 11.10.0 + +### Important Changes + +- **feat: Add a managed tunnel that the SDK derives from the DSN ([#25199](https://github.com/getsentry/sentry-javascript/pull/25199), [#25200](https://github.com/getsentry/sentry-javascript/pull/25200), [#25783](https://github.com/getsentry/sentry-javascript/pull/25783))** + + Set `tunnel: true` in your browser `Sentry.init` call to send events through your own server. This helps when ad blockers block requests to Sentry. The browser derives the tunnel path from the DSN, so older and newer builds of your client and server always agree on the path. + + To serve the tunnel, add `serveTunnelIntegration()` to your server SDK. Astro, Nuxt, Nitro, React Router, Remix 3, SolidStart, SvelteKit and TanStack Start then answer tunnel requests automatically: + + ```ts + // browser + Sentry.init({ dsn, tunnel: true }); + + // server + Sentry.init({ dsn, integrations: [Sentry.serveTunnelIntegration()] }); + ``` + + If the browser uses a different DSN than the server, set `serveTunnelIntegration({ allowedDsns: [browserDsn] })`. For other setups, the server SDKs export `handleTunnelRequestIfMatched`, `handleTunnelRequest`, `isTunnelRequest` and `getTunnelPath`: + + ```ts + app.use((req, res, next) => Sentry.handleTunnelRequestIfMatched(req, res) ?? next()); + ``` + + The `request` option of `handleTunnelRequest` is deprecated and will be removed in v12. + +- **feat: Make span origins spec compliant ([#25602](https://github.com/getsentry/sentry-javascript/pull/25602))** + + Some span origins changed their names to follow the [trace origin spec](https://develop.sentry.dev/sdk/telemetry/traces/trace-origin/): + - `auto.amqplib.*` is now `auto.queue.amqplib.*`, and `auto.kafkajs.*` is now `auto.queue.kafkajs.*`. + - `auto.vercelai.channel` is now `auto.ai.vercelai`. + - `auto.firebase.firestore` is now `auto.db.firebase.firestore`, and `auto.firebase.functions` is now `auto.function.firebase`. + - `auto.aws.aws_sdk` is now `auto.{rpc,db,queue,ai}.aws_sdk`, with the category from the span op. + - `auto.aws_lambda` is now `auto.function.aws_serverless`. + - `auto.mcp.notification` is now `auto.function.mcp_server.notification`. + - `auto.db.cloudflare.durable_object.sql` is now `auto.db.cloudflare.durable_object_sql`. + - `auto.http.browser.{lcp,cls,inp}` is now `auto.ui.browser.{lcp,cls,inp}`. + + If you have `ignoreSpans` rules, queries or alerts on the old origins, change them to use the new origins. + +- **feat(bun): Add incoming request options and request sessions to `bunServerIntegration` ([#25580](https://github.com/getsentry/sentry-javascript/pull/25580))** + + `bunServerIntegration` now accepts the same options as `denoServeIntegration`: `ignoreRequestBody`, `ignoreIncomingRequests`, `ignoreStaticAssets`, `spans`, `onSpanCreated`, `onSpanEnd`, `sessions` and `sessionFlushingDelayMS`. The callbacks receive the web `Request`. + + `Bun.serve` now records a request session for each incoming request. Sessions are sent only when a `release` is set. To turn them off, set `bunServerIntegration({ sessions: false })`. + + `ignoreStaticAssets` defaults to `true`, so `Bun.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 `bunServerIntegration({ ignoreStaticAssets: false })`. `Bun.serve` spans also follow the client's tracing configuration by default, so the integration does not start spans when tracing is disabled. Errors thrown for `OPTIONS` and `HEAD` requests are now captured. + +- **feat(bun): Inject debug IDs and upload source maps in `sentryBunPlugin` ([#25670](https://github.com/getsentry/sentry-javascript/pull/25670))** + + `sentryBunPlugin` from `@sentry/bun/plugin` now accepts the same options as `sentryEsbuildPlugin` (`org`, `project`, `authToken`, `release`, `sourcemaps`, ...). When `Bun.build` writes source maps to `outdir`, the plugin injects the debug ID that Bun writes into each bundle, injects the release, and uploads the source maps at the end of the build. You no longer need to run `sentry sourcemap inject` and `sentry sourcemap upload` after the build. + + Like the other bundler plugins, `sentryBunPlugin` now sends build telemetry and injects the release it detects (for example, from git). To turn off telemetry, set `telemetry: false`. + +- **feat(cloudflare): Add incoming request span options to `httpServerIntegration` ([#25582](https://github.com/getsentry/sentry-javascript/pull/25582))** + + Cloudflare's `httpServerIntegration` now accepts `ignoreIncomingRequests`, `ignoreStaticAssets`, `spans`, `onSpanCreated` and `onSpanEnd`, with the same meaning as in the Node, Deno and Bun integrations. The callbacks receive the web `Request`. + + `ignoreStaticAssets` defaults to `true`, so workers no longer create spans for `GET` requests to common static assets such as `favicon.ico`, `robots.txt` or `*.js` files. To keep these spans, set `httpServerIntegration({ ignoreStaticAssets: false })`. Request spans also follow the client's tracing configuration by default, so the SDK does not start them when tracing is disabled. If `ignoreRequestBody` throws, the request no longer fails, and the SDK skips the body. + +- **feat(cloudflare): Let users configure the channel integrations ([#25584](https://github.com/getsentry/sentry-javascript/pull/25584))** + + When the `@sentry/cloudflare/vite` plugin bundles an instrumented package (for example `pg` or `openai`), the SDK adds the matching integration with its default options. `@sentry/cloudflare` now exports these integrations, so you can set their options, for example in `instrument.server.ts`: + + ```ts + export default Sentry.defineCloudflareOptions(env => ({ + dsn: env.SENTRY_DSN, + integrations: [Sentry.openAIIntegration({ recordInputs: false, recordOutputs: false })], + })); + ``` + + These integrations need the vite plugin. Without it, they record nothing, and with `debug: true` the SDK logs a warning. + +- **feat(node): Add `onSpanEnd` to `httpIntegration` ([#25581](https://github.com/getsentry/sentry-javascript/pull/25581))** + + `httpIntegration` and `httpServerSpansIntegration` now accept `onSpanEnd`, a hook that runs when the response for an incoming request finishes, before its span ends. Use it to change the span based on the response. Deno and Bun already have this option. + +- **feat(node, bun, deno, cloudflare): Align portable exports across server SDKs ([#25583](https://github.com/getsentry/sentry-javascript/pull/25583))** + + Node, Bun, Deno and Cloudflare now export the same helpers from `@sentry/core` and `@sentry/server-utils` where they work on that runtime: + + - Node and Bun now export `moduleMetadataIntegration`, `instrumentPostgresJsSql`, `instrumentCreateReactAgent`, `awsIntegration` and the `FlueOptions` type. `awsIntegration` was already in their default integrations. + - Deno and Cloudflare now export `parameterize` and `instrumentStateGraphCompile`. + - Bun and Cloudflare now export the `Log` and `LogSeverityLevel` types. + - Deno now exports `fastifyIntegration`, which was already in its default integrations. + +- **feat(nuxt): Start the server SDK on the Nuxt Vite server (experimental) ([#25524](https://github.com/getsentry/sentry-javascript/pull/25524))** + + Sentry now supports Node.js production builds using Nuxt 4.6's experimental Vite server (`server: { builder: 'vite' }`). Your `sentry.server.config` loads automatically, enabling request tracing and automatic database spans for supported libraries. + + Development mode, Cloudflare, automatic capture of server rendering errors, and connecting server and browser traces via HTML meta tags are not yet supported. This experimental support may change in minor releases. + +- **ref(deno, bun): Use a shared request handler wrapper for `Deno.serve` and `Bun.serve` ([#25586](https://github.com/getsentry/sentry-javascript/pull/25586), [#25587](https://github.com/getsentry/sentry-javascript/pull/25587))** + + `Deno.serve` and `Bun.serve` now use the same request handler wrapper, which Cloudflare will also use. On both runtimes, errors in `OPTIONS` and `HEAD` requests now carry the request data, and the SDK keeps every `Set-Cookie` header of a response instead of only the last one. + + For Deno users: + + - When the source of a streamed response fails, the SDK now captures the error and marks the span as failed. + - The SDK no longer reads a streamed response ahead of the client. + + `Bun.serve` spans now follow the Sentry conventions for `http.server` spans, as on Node, Deno and Cloudflare: + + - `url.template` is now `http.route`. Routes without parameters also get `http.route` and the `route` source. + - `url.domain` is now `server.address`. + - A request to `/` gets the `route` source and the name `GET /`. + - Spans also get `user_agent.original` and `http.request.body.size`. + +### Other Changes + +- chore(deps): Bump rrweb to 2.45.0 ([#25794](https://github.com/getsentry/sentry-javascript/pull/25794)) +- feat: Export the Node bundler plugins from SDKs that wrap @sentry/node ([#25536](https://github.com/getsentry/sentry-javascript/pull/25536)) +- feat(cloudflare): instrument Workers KV bindings ([#25488](https://github.com/getsentry/sentry-javascript/pull/25488)) +- feat(nextjs): Warn when Next.js production builds miss the instrumentation file ([#25603](https://github.com/getsentry/sentry-javascript/pull/25603)) +- feat(react-router): Support renderToReadableStream in createSentryHandleRequest ([#25254](https://github.com/getsentry/sentry-javascript/pull/25254)) +- feat(server-utils): Emit `execute_tool` spans for Anthropic's `beta.sessions.events.toolRunner()` ([#25554](https://github.com/getsentry/sentry-javascript/pull/25554)) +- feat(server-utils): Record Workers AI Clairic calls as gen_ai.extract spans ([#25624](https://github.com/getsentry/sentry-javascript/pull/25624)) +- feat(server-utils): Instrument together-ai 1.x ([#25823](https://github.com/getsentry/sentry-javascript/pull/25823)) +- fix(browser): Preserve empty stacks in continuous profiles ([#25764](https://github.com/getsentry/sentry-javascript/pull/25764)) +- fix(browser): resolve request URLs against location.href in tracing ([#25784](https://github.com/getsentry/sentry-javascript/pull/25784)) +- fix(build): Make CJS re-exports visible to Node ESM imports ([#25599](https://github.com/getsentry/sentry-javascript/pull/25599)) +- fix(bun): Add systemErrorIntegration to default integrations ([#25681](https://github.com/getsentry/sentry-javascript/pull/25681)) +- fix(cloudflare): Bind a default re-export with `var` in the Vite transform ([#25663](https://github.com/getsentry/sentry-javascript/pull/25663)) +- fix(cloudflare): Do not add back channel integrations the user removed ([#25585](https://github.com/getsentry/sentry-javascript/pull/25585)) +- fix(cloudflare): Skip comments when locating the `default` keyword in the Vite transform ([#25741](https://github.com/getsentry/sentry-javascript/pull/25741)) +- fix(core): allow project arrays in framework build options ([#25534](https://github.com/getsentry/sentry-javascript/pull/25534)) +- fix(core): Drop sampled-out errors before they take a promise buffer slot ([#25751](https://github.com/getsentry/sentry-javascript/pull/25751)) +- fix(core): End node:http client spans when the response is destroyed ([#25503](https://github.com/getsentry/sentry-javascript/pull/25503)) +- fix(core): Preserve AI proxy property references ([#22474](https://github.com/getsentry/sentry-javascript/pull/22474)) +- fix(core): Resolve relative URLs when enhancing fetch error messages ([#25555](https://github.com/getsentry/sentry-javascript/pull/25555)) +- fix(feedback): Wait for frames without the form before capturing the screenshot ([#25658](https://github.com/getsentry/sentry-javascript/pull/25658)) +- fix(hono): Make the Hono middleware types compile on Hono 5 ([#25452](https://github.com/getsentry/sentry-javascript/pull/25452)) +- fix(node): Flush buffered streamed spans on process exit ([#25539](https://github.com/getsentry/sentry-javascript/pull/25539)) +- fix(node): Read module versions from the isolated layouts of pnpm and Bun ([#25526](https://github.com/getsentry/sentry-javascript/pull/25526)) +- fix(node): Set the eve conversation id on model.started for eve 0.84+ ([#25809](https://github.com/getsentry/sentry-javascript/pull/25809)) +- fix(node): Skip spans for outgoing requests to Sentry via `undici` fetch and `node:http` ([#25441](https://github.com/getsentry/sentry-javascript/pull/25441)) +- fix(node): Use the request method for Hapi routes with multiple methods ([#25527](https://github.com/getsentry/sentry-javascript/pull/25527)) +- fix(nuxt,nitro): Accept Nitro 3 betas without narrowing peer support ([#25815](https://github.com/getsentry/sentry-javascript/pull/25815)) +- fix(opentelemetry): Convert OpenTelemetry performance.now() timestamps ([#25790](https://github.com/getsentry/sentry-javascript/pull/25790)) +- fix(react): Avoid double slashes in manifest route names ([#25770](https://github.com/getsentry/sentry-javascript/pull/25770)) +- fix(react): Parameterize React Router transactions with encoded params ([#25639](https://github.com/getsentry/sentry-javascript/pull/25639)) +- fix(remix): Drop Remix 3 release candidates from the peer range ([#25808](https://github.com/getsentry/sentry-javascript/pull/25808)) +- fix(replay): Apply attribute masking to breadcrumb messages ([#25180](https://github.com/getsentry/sentry-javascript/pull/25180)) +- fix(server-utils): Cap the runs the LangChain callback handler keeps open ([#25595](https://github.com/getsentry/sentry-javascript/pull/25595)) +- fix(server-utils): Keep OpenAI streamed tool calls that have no index ([#25622](https://github.com/getsentry/sentry-javascript/pull/25622)) +- fix(server-utils): Keep OpenRouter evaluation confidence and legend on Vercel AI spans ([#25644](https://github.com/getsentry/sentry-javascript/pull/25644)) +- fix(server-utils): Nest Flue runs on Node under the request that dispatched them ([#25549](https://github.com/getsentry/sentry-javascript/pull/25549)) +- fix(server-utils): Record AI SDK tool spans for tools run outside its tool loop ([#25820](https://github.com/getsentry/sentry-javascript/pull/25820)) +- fix(server-utils): Use cumulative input and cache token counts from streamed message_delta ([#25623](https://github.com/getsentry/sentry-javascript/pull/25623)) +- fix(server-utils): Use the stored pi-durable session id as conversation id ([#25792](https://github.com/getsentry/sentry-javascript/pull/25792)) +- fix(tanstackstart-react): Unminify server errors in Nitro builds ([#25360](https://github.com/getsentry/sentry-javascript/pull/25360)) + +
+ Internal Changes + +- chore: Add catch-all rule for shared packages in CODEOWNERS ([#25789](https://github.com/getsentry/sentry-javascript/pull/25789)) +- chore: Pin pnpm 10 in Volta and remove .npmrc ([#25641](https://github.com/getsentry/sentry-javascript/pull/25641)) +- chore: Replace Volta with pnpm-managed node versions ([#25689](https://github.com/getsentry/sentry-javascript/pull/25689)) +- chore: Update PR template and prompt agent to use it ([#25763](https://github.com/getsentry/sentry-javascript/pull/25763)) +- chore: Use the isolated pnpm node_modules layout ([#25368](https://github.com/getsentry/sentry-javascript/pull/25368)) +- chore(ci): remove browser flaky-test detector workflow ([#25712](https://github.com/getsentry/sentry-javascript/pull/25712)) +- chore(ci): remove PR review reminders ([#25711](https://github.com/getsentry/sentry-javascript/pull/25711)) +- chore(deps-dev): Bump @remix-run/route-pattern from 0.24.1 to 1.0.0 in the remix group across 1 directory ([#25652](https://github.com/getsentry/sentry-javascript/pull/25652)) +- chore(deps-dev): Bump es-check from 7.2.1 to 9.8.1 ([#25656](https://github.com/getsentry/sentry-javascript/pull/25656)) +- chore(deps-dev): Bump hono from 4.13.9 to 4.13.11 in /packages/hono ([#25716](https://github.com/getsentry/sentry-javascript/pull/25716)) +- chore(deps-dev): Bump next from 15.5.27 to 15.5.28 in /packages/nextjs ([#25661](https://github.com/getsentry/sentry-javascript/pull/25661)) +- chore(deps-dev): Bump zod from 3.25.76 to 4.6.7 ([#25655](https://github.com/getsentry/sentry-javascript/pull/25655)) +- chore(deps): Bump next from 16.3.8 to 16.4.1 in /dev-packages/e2e-tests/test-applications/vinext-cf-workers ([#25672](https://github.com/getsentry/sentry-javascript/pull/25672)) +- chore(deps): Bump zod from 4.5.4 to 4.6.6 in /dev-packages/e2e-tests/test-applications/node-eve ([#25722](https://github.com/getsentry/sentry-javascript/pull/25722)) +- chore(deps): Bump zod from 4.5.4 to 4.6.6 in /dev-packages/e2e-tests/test-applications/node-mastra ([#25721](https://github.com/getsentry/sentry-javascript/pull/25721)) +- ci: Always report optional E2E results and update the report on re-runs ([#25651](https://github.com/getsentry/sentry-javascript/pull/25651)) +- ci: Fail the optional E2E report job when it cannot update the PR comment ([#25819](https://github.com/getsentry/sentry-javascript/pull/25819)) +- ci: Prepare required workflows for develop merge queue ([#25642](https://github.com/getsentry/sentry-javascript/pull/25642)) +- ci: Remove temporary pull_request trigger from optional E2E workflow ([#25817](https://github.com/getsentry/sentry-javascript/pull/25817)) +- ci: Show an unknown status for optional E2E jobs without a final result ([#25660](https://github.com/getsentry/sentry-javascript/pull/25660)) +- ci: Upload the weekly flaky test report as an artifact ([#25814](https://github.com/getsentry/sentry-javascript/pull/25814)) +- ci(e2e): rightsize namespace runner jobs +- ci(size-limit): Measure bundles when increases are accepted ([#25788](https://github.com/getsentry/sentry-javascript/pull/25788)) +- docs: Update contribution guidelines ([#25714](https://github.com/getsentry/sentry-javascript/pull/25714)) +- docs: Use dark-mode-aware Sentry logo in READMEs ([#25775](https://github.com/getsentry/sentry-javascript/pull/25775)) +- feat(deps): Bump fastify from 5.12.5 to 5.12.7 ([#25654](https://github.com/getsentry/sentry-javascript/pull/25654)) +- docs(changelog): Clarify React Router framework mode minimum in 11.0.0 ([#25771](https://github.com/getsentry/sentry-javascript/pull/25771)) +- ref(core): Add shared `ServerRequestOptions` type across runtimes ([#25579](https://github.com/getsentry/sentry-javascript/pull/25579)) +- ref(hono): Share the middleware classification ([#25453](https://github.com/getsentry/sentry-javascript/pull/25453)) +- ref(server-utils): Use `GEN_AI_EXTRACT` op from `@sentry/conventions` ([#25734](https://github.com/getsentry/sentry-javascript/pull/25734)) +- Remove permissions from settings.json ([#25766](https://github.com/getsentry/sentry-javascript/pull/25766)) +- test: Check that runtimes drop the server spans of managed tunnel requests ([#25414](https://github.com/getsentry/sentry-javascript/pull/25414)) +- test: Fix unit test timeouts from cold dynamic imports ([#25811](https://github.com/getsentry/sentry-javascript/pull/25811)) +- test: Wire the managed tunnel by hand in Node, Cloudflare and Next.js ([#25785](https://github.com/getsentry/sentry-javascript/pull/25785)) +- test(browser): Fix flaky long task, pageload cancellation and CLS timing assertions ([#25518](https://github.com/getsentry/sentry-javascript/pull/25518)) +- test(cloudflare): Add Workers AI E2E test for Clairic ([#25625](https://github.com/getsentry/sentry-javascript/pull/25625)) +- test(cloudflare): Bump agents and MCP SDK in cloudflare-mcp-agent e2e app ([#25830](https://github.com/getsentry/sentry-javascript/pull/25830)) +- test(cloudflare): Check that Durable Object invocations end when the method returns ([#25511](https://github.com/getsentry/sentry-javascript/pull/25511)) +- test(cloudflare): Fix flaky D1 query span naming test ([#25810](https://github.com/getsentry/sentry-javascript/pull/25810)) +- test(e2e): Add node-flue-send-to-sentry test app ([#25550](https://github.com/getsentry/sentry-javascript/pull/25550)) +- test(e2e): Assert source map upload and stripping with a shared helper ([#25636](https://github.com/getsentry/sentry-javascript/pull/25636)) +- test(e2e): Fix optional Anthropic and OpenAI decisions apps ([#25795](https://github.com/getsentry/sentry-javascript/pull/25795)) +- test(e2e): Pin @mastra/deployer in node-mastra to match @mastra/core ([#25824](https://github.com/getsentry/sentry-javascript/pull/25824)) +- test(e2e): Pull Supabase images from GHCR ([#25718](https://github.com/getsentry/sentry-javascript/pull/25718)) +- test(nextjs): Raise @opennextjs/cloudflare floor for nextjs-16 ([#25682](https://github.com/getsentry/sentry-javascript/pull/25682)) +- test(nextjs): Unpin nextjs-16-cf-workers latest variant back to next@latest ([#25124](https://github.com/getsentry/sentry-javascript/pull/25124)) +- test(node): Retry stalled npm installs in integration test setup ([#25685](https://github.com/getsentry/sentry-javascript/pull/25685)) +- test(react-router): Use a separate URL per loader error test in instrumentation API e2e test ([#25607](https://github.com/getsentry/sentry-javascript/pull/25607)) +- test(remix): Cover prefetched navigations in the Remix 3 E2E app ([#25829](https://github.com/getsentry/sentry-javascript/pull/25829)) +- test(replay): Fix flaky EventBufferCompressionWorker size limit test ([#25519](https://github.com/getsentry/sentry-javascript/pull/25519)) +- test(tanstackstart-react): Add source map E2E app ([#25363](https://github.com/getsentry/sentry-javascript/pull/25363)) + +
+ +Work in this release was contributed by @4ktLuffy, @faizansaiyed123, @itisrmk, @kunal649, @kyrylolvov, @larcane97, @lfantone, @pcbeingused333, @psh4607, and @seongwon030. Thank you for your contributions! + ## 11.9.0 ### Important Changes @@ -1122,7 +1333,7 @@ A comprehensive migration guide outlining all changes can be found within the Se #### @sentry/react-router -- feat(react-router)!: Raise minimum supported React Router version to >= 7.15 ([#22481](https://github.com/getsentry/sentry-javascript/pull/22481)) +- feat(react-router)!: Raise minimum supported React Router (framework mode) version to >= 7.15 ([#22481](https://github.com/getsentry/sentry-javascript/pull/22481)) - feat(react-router)!: Remove deprecated server wrappers ([#22529](https://github.com/getsentry/sentry-javascript/pull/22529)) - feat(react-router)!: Remove server OTel instrumentation ([#22551](https://github.com/getsentry/sentry-javascript/pull/22551)) - feat(react-router)!: Remove unstable_sentryVitePluginOptions ([#23374](https://github.com/getsentry/sentry-javascript/pull/23374)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c65ca5d97765..79a1beab5799 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,31 +1,47 @@

- Sentry + + + + Sentry +

# Contributing -We welcome suggested improvements and bug fixes to the `@sentry/*` family of packages, in the form of pull requests on -[`GitHub`](https://github.com/getsentry/sentry-javascript). The guide below will help you get started, but if you have -further questions, please feel free to reach out on [Discord](https://discord.gg/Ww9hbqr). To learn about some general -SDK development principles check out the [SDK Development Guide](https://develop.sentry.dev/sdk/) in the Sentry -Developer Documentation. +We welcome suggested improvements and bug fixes to the `@sentry/*` family of packages. If you're not on the Sentry +team, read [External contributors](#external-contributors) before you open a pull request. The guide below will help +you get started, but if you have further questions, please feel free to reach out on +[Discord](https://discord.gg/Ww9hbqr). To learn about some general SDK development principles check out the +[SDK Development Guide](https://develop.sentry.dev/sdk/) in the Sentry Developer Documentation. ## Setting up an Environment -We use [Volta](https://volta.sh/) to ensure we use consistent versions of node and pnpm. Make sure to enable -[pnpm support in Volta](https://docs.volta.sh/advanced/pnpm). +`sentry-javascript` is a monorepo containing several packages, managed with pnpm workspaces and Nx. pnpm installs the +Node.js and pnpm versions the repo pins. -`sentry-javascript` is a monorepo containing several packages, managed with pnpm workspaces and Nx. To get started, -install all dependencies, and then perform an initial build, so TypeScript can read all of the linked type definitions. +1. Install pnpm 12 or newer, see the [pnpm installation docs](https://pnpm.io/installation). +2. Install a default Node.js version through pnpm: -``` -$ pnpm install -$ pnpm run build -``` + ```sh + pnpm runtime set node 24 -g + ``` + + If this fails with `ERR_PNPM_GLOBAL_BIN_DIR_NOT_IN_PATH` (for example because pnpm came from npm or Homebrew instead + of the install script), run [`pnpm setup`](https://pnpm.io/cli/setup) to add pnpm's global bin directory to your + `PATH`, restart your shell, and try again. + +3. Install all dependencies, and then perform an initial build, so TypeScript can read all of the linked type + definitions: -With that, the repo is fully set up and you are ready to run all commands. + ```sh + pnpm install + pnpm run build + ``` + +With that, the repo is fully set up and you are ready to run all commands. If you used Volta before, or `node --version` +doesn't match the repo, see [Node.js and pnpm Versions](#nodejs-and-pnpm-versions). ## Building Packages @@ -78,8 +94,8 @@ E2E tests verify SDK behavior in real-world framework scenarios. ### Prerequisites 1. **Docker**: Required for some E2E tests -2. **Volta**: The E2E tests run each app on the node version that it pins. Without Volta, they use your current node - version. +2. **pnpm-managed Node.js** (see [Setting up an Environment](#setting-up-an-environment)): The E2E tests run each app on + the Node.js version that it pins. Without pnpm's `node` shim, they use your current Node.js version. ### Step-by-Step Instructions @@ -164,17 +180,39 @@ Similar to building and testing, linting can be done in the project root or in i Note: you must run `pnpm run build` before `pnpm run lint` will work. -## External Contributors +## External contributors + +Before you write code, get a maintainer's go-ahead in an issue. That way, nobody spends a weekend on a PR that just +sits in our queue. We check that the change fits our plans and that we have time to review it. Code details can wait +for the PR review. + +1. Find or open an issue for your change. Describe the problem and what you want to change. +2. Wait for a maintainer to give you the go-ahead. +3. If someone else is assigned to the issue, ask them in the issue before you start. +4. Open a PR against `develop` and link the issue, for example `Fixes #1234`. -We highly appreciate external contributions to the SDK. If you want to contribute something, you can just open a PR -against `develop`. +This also applies to small changes. We may close PRs without a go-ahead. -The SDK team will check out your PR shortly! +### Using AI tools -When contributing to the codebase, please note: +Use any tools you like. You're still the one who answers the review comments, so read, test, and understand every +change before you submit it. We close PRs that look like unreviewed agent output. -- Make sure to follow the [Commit, Issue & PR guidelines](./docs/commit-issue-pr-guidelines.md) -- Non-trivial PRs will not be accepted without tests (see above). +### Before you open a PR + +- Follow the [Commit, Issue & PR guidelines](./docs/commit-issue-pr-guidelines.md). +- Add tests for non-trivial changes (see [Adding Tests](#adding-tests)). +- Keep each PR to one change. Put unrelated refactors in a separate PR. +- Run `pnpm run lint` and the tests for the packages you changed. + +### After you open a PR + +Ideally, answer review comments within a few hours, and within a day at the latest. While the change is still fresh in +the reviewer's head, each round is quick, and a PR that keeps moving is more likely to get merged. We may close PRs +that go quiet for a long time. + +We try to review PRs quickly, but now and then one slips through the cracks. That's on us, not you. If you haven't heard +back in a few days, `@` mention a maintainer to ping us. ## Commit, Issue & PR guidelines @@ -189,3 +227,21 @@ See [PR Reviews](./docs/pr-reviews.md). _These steps are only relevant to Sentry employees when preparing and publishing a new SDK release._ [See the docs for publishing a release](./docs/publishing-a-release.md) + +## Node.js and pnpm Versions + +- The root `package.json` pins Node.js in `devEngines.runtime` and pnpm in `packageManager`. CI reads the same fields. +- pnpm's `node` command runs the Node.js version of the nearest `package.json` that pins one + ([project-aware global bins](https://pnpm.io/global-packages#project-aware-global-bins)). Packages and E2E test apps + inherit the root version, and can override it with their own `devEngines.runtime`. + +If `node --version` or `pnpm --version` doesn't match the repo, run `which node pnpm`. Both should be in +`$PNPM_HOME/bin`. If not, another tool (Volta, nvm, fnm, corepack, Homebrew Node.js) comes first in your `PATH`. Remove it, +or move `$PNPM_HOME/bin` before it. + +### Migrating from Volta + +The repo no longer has a `volta` field, so Volta runs its default Node.js instead of the version the repo pins. +[Uninstall Volta](https://docs.volta.sh/advanced/uninstall) and follow the +[setup](#setting-up-an-environment). If other projects still need Volta, keep it, but put `$PNPM_HOME/bin` before +`~/.volta/bin` in your `PATH`, and use `volta run node` in those projects. diff --git a/README.md b/README.md index 688bf7e72f79..0d82b7f93172 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@

- Sentry + + + + Sentry +

diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index ae475f715a8c..c42609533af1 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -59,11 +59,11 @@ "@babel/core": "^7.29.6", "@babel/preset-typescript": "^7.16.7", "@playwright/test": "1.64.1", - "@sentry/rrweb": "2.44.1", + "@sentry/rrweb": "2.45.0", "@sentry/browser": "11.9.0", "@sentry/replay": "11.9.0", "@sentry/opentelemetry": "11.9.0", - "@sentry/conventions": "0.27.0", + "@sentry/conventions": "0.29.0", "@supabase/supabase-js": "2.109.0", "axios": "1.20.0", "babel-loader": "^10.1.1", @@ -72,11 +72,13 @@ "webpack": "^5.95.0" }, "devDependencies": { + "@sentry/browser-utils": "11.9.0", + "@sentry/core": "11.9.0", + "@sentry/replay-canvas": "11.9.0", + "@sentry/rrweb-types": "^2.45.0", + "@sentry/wasm": "11.9.0", "@types/node": "^20.19.0", "eslint-plugin-regexp": "^3.1.0", "glob": "^13.0.6" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/browser-integration-tests/suites/feedback/screenshotCapture/init.js b/dev-packages/browser-integration-tests/suites/feedback/screenshotCapture/init.js new file mode 100644 index 000000000000..94453fb8e47a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/feedback/screenshotCapture/init.js @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/browser'; +import { feedbackIntegration } from '@sentry/browser'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [feedbackIntegration({ colorScheme: 'light' })], +}); diff --git a/dev-packages/browser-integration-tests/suites/feedback/screenshotCapture/test.ts b/dev-packages/browser-integration-tests/suites/feedback/screenshotCapture/test.ts new file mode 100644 index 000000000000..2a4a25f8ca75 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/feedback/screenshotCapture/test.ts @@ -0,0 +1,56 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { shouldSkipFeedbackTest } from '../../../utils/helpers'; + +type CaptureWindow = Window & { framesWithoutFormAtCapture?: number }; + +// Whether the capture still shows the form depends on how busy the machine is, so this counts the frames the page +// drew with the form hidden by the time the capture is requested instead. +sentryTest( + 'draws two frames without the feedback form before it asks for the screen capture', + async ({ getLocalTestUrl, page, browserName }) => { + sentryTest.skip(shouldSkipFeedbackTest()); + sentryTest.skip(browserName !== 'chromium', 'The canvas screen-capture fixture requires Chromium.'); + + await page.addInitScript(() => { + // The fixture runs over HTTP, but screenshot capture is stubbed below. + Object.defineProperty(window, 'isSecureContext', { value: true }); + + let framesWithoutForm = 0; + const countFrame = (): void => { + const form = document + .getElementById('sentry-feedback') + ?.shadowRoot?.querySelector('dialog.dialog')?.parentElement; + if (form?.style.display === 'none') { + framesWithoutForm += 1; + } + requestAnimationFrame(countFrame); + }; + requestAnimationFrame(countFrame); + + Object.defineProperty(navigator, 'mediaDevices', { + value: { + getDisplayMedia: async () => { + (window as CaptureWindow).framesWithoutFormAtCapture = framesWithoutForm; + const canvas = document.createElement('canvas'); + canvas.width = 1600; + canvas.height = 1000; + const ctx = canvas.getContext('2d')!; + ctx.fillStyle = 'white'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + return canvas.captureStream(); + }, + }, + configurable: true, + }); + }); + + await page.goto(await getLocalTestUrl({ testDir: __dirname, handleLazyLoadedFeedback: true })); + await page.getByRole('button', { name: 'Report a Bug', exact: true }).click(); + await page.getByRole('button', { name: 'Add a screenshot', exact: true }).click(); + await expect(page.locator('#foreground')).toBeVisible(); + + const framesWithoutForm = await page.evaluate(() => (window as CaptureWindow).framesWithoutFormAtCapture); + expect(framesWithoutForm).toBeGreaterThanOrEqual(2); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts b/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts index a83f6e9fa5ce..8a9c0046f5b4 100644 --- a/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts @@ -97,7 +97,7 @@ sentryTest( expect.arrayContaining([ { ...expectedClickBreadcrumb, - message: 'body > div#error.btn.btn-error[aria-label="An Error in aria-label"]', + message: 'body > div#error.btn.btn-error[aria-label="** ***** ** **********"]', data: { nodeId: expect.any(Number), node: { @@ -124,7 +124,7 @@ sentryTest( expect.arrayContaining([ { ...expectedClickBreadcrumb, - message: 'body > button#img-button[title="Button title"]', + message: 'body > button#img-button[title="****** *****"]', data: { nodeId: expect.any(Number), node: { diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-static/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-static/test.ts index 0a3f97f6a786..4bab75a82e99 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-static/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-static/test.ts @@ -59,7 +59,7 @@ sentryTest( const duration = end - start; expect(duration).toBeGreaterThanOrEqual(0.1); - expect(duration).toBeLessThanOrEqual(0.15); + expect(duration).toBeLessThan(1); }, ); @@ -111,6 +111,6 @@ sentryTest( const duration = end - start; expect(duration).toBeGreaterThanOrEqual(0.1); - expect(duration).toBeLessThanOrEqual(0.15); + expect(duration).toBeLessThan(1); }, ); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled/test.ts index 8a1cc8026aaa..181c5457838b 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled/test.ts @@ -64,7 +64,7 @@ sentryTest( const duration = end - start; expect(duration).toBeGreaterThanOrEqual(0.1); - expect(duration).toBeLessThanOrEqual(0.15); + expect(duration).toBeLessThan(1); }, ); @@ -114,5 +114,5 @@ sentryTest('captures long animation frame span for event listener.', async ({ br const duration = end - start; expect(duration).toBeGreaterThanOrEqual(0.1); - expect(duration).toBeLessThanOrEqual(0.15); + expect(duration).toBeLessThan(1); }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-static/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-static/test.ts index fb252c80fa30..5881bf8b3ba0 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-static/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-static/test.ts @@ -61,7 +61,7 @@ sentryTest( const duration = end - start; expect(duration).toBeGreaterThanOrEqual(0.1); - expect(duration).toBeLessThanOrEqual(0.15); + expect(duration).toBeLessThan(1); }, ); @@ -113,6 +113,6 @@ sentryTest( const duration = end - start; expect(duration).toBeGreaterThanOrEqual(0.1); - expect(duration).toBeLessThanOrEqual(0.15); + expect(duration).toBeLessThan(1); }, ); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled/test.ts index 192956b477d9..6a2638a61c45 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled/test.ts @@ -66,7 +66,7 @@ sentryTest( const duration = end - start; expect(duration).toBeGreaterThanOrEqual(0.1); - expect(duration).toBeLessThanOrEqual(0.15); + expect(duration).toBeLessThan(1); }, ); @@ -116,5 +116,5 @@ sentryTest('captures long animation frame span for event listener.', async ({ br const duration = end - start; expect(duration).toBeGreaterThanOrEqual(0.1); - expect(duration).toBeLessThanOrEqual(0.15); + expect(duration).toBeLessThan(1); }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-static/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-static/test.ts index e34b0d779f7e..5ca64416fd71 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-static/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-static/test.ts @@ -19,18 +19,19 @@ sentryTest('should capture long task.', async ({ browserName, getLocalTestUrl, p expect(uiSpans?.length).toBeGreaterThan(0); - const [firstUISpan] = uiSpans || []; - expect(firstUISpan).toEqual( + const getDuration = (span: { start_timestamp?: number; timestamp?: number }): number => + (span.timestamp ?? 0) - (span.start_timestamp ?? 0); + // Other long tasks (e.g. SDK evaluation) can happen during pageload, so look at the longest one, which is the blocking script + const [longTaskSpan] = [...(uiSpans || [])].sort((a, b) => getDuration(b) - getDuration(a)); + expect(longTaskSpan).toEqual( expect.objectContaining({ op: 'ui.long_task', description: 'Main UI thread blocked', parent_span_id: eventData.contexts?.trace?.span_id, }), ); - const start = firstUISpan.start_timestamp ?? 0; - const end = firstUISpan.timestamp ?? 0; - const duration = end - start; + const duration = getDuration(longTaskSpan); expect(duration).toBeGreaterThanOrEqual(0.1); - expect(duration).toBeLessThanOrEqual(0.15); + expect(duration).toBeLessThan(1); }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled/test.ts index add1a8130425..9dc2b8d249cf 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled/test.ts @@ -24,8 +24,11 @@ sentryTest('captures long task.', async ({ browserName, getLocalTestUrl, page }) const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui')); expect(uiSpans.length).toBeGreaterThan(0); - const [firstUISpan] = uiSpans; - expect(firstUISpan).toEqual( + const getDuration = (span: { start_timestamp?: number; end_timestamp?: number }): number => + (span.end_timestamp ?? 0) - (span.start_timestamp ?? 0); + // Other long tasks (e.g. SDK evaluation) can happen during pageload, so look at the longest one, which is the blocking script + const [longTaskSpan] = [...uiSpans].sort((a, b) => getDuration(b) - getDuration(a)); + expect(longTaskSpan).toEqual( expect.objectContaining({ name: 'Main UI thread blocked', parent_span_id: pageloadSpan.span_id, @@ -35,10 +38,8 @@ sentryTest('captures long task.', async ({ browserName, getLocalTestUrl, page }) }), ); - const start = firstUISpan.start_timestamp ?? 0; - const end = firstUISpan.end_timestamp ?? 0; - const duration = end - start; + const duration = getDuration(longTaskSpan); expect(duration).toBeGreaterThanOrEqual(0.1); - expect(duration).toBeLessThanOrEqual(0.15); + expect(duration).toBeLessThan(1); }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-no-animation-frame/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-no-animation-frame/test.ts index 707a336d3df1..9ca47dc01e4b 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-no-animation-frame/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-no-animation-frame/test.ts @@ -26,18 +26,19 @@ sentryTest('should capture long task.', async ({ browserName, getLocalTestUrl, p expect(uiSpans.length).toBeGreaterThan(0); - const [firstUISpan] = uiSpans; - expect(firstUISpan).toEqual( + const getDuration = (span: { start_timestamp?: number; end_timestamp?: number }): number => + (span.end_timestamp ?? 0) - (span.start_timestamp ?? 0); + // Other long tasks (e.g. SDK evaluation) can happen during pageload, so look at the longest one, which is the blocking script + const [longTaskSpan] = [...uiSpans].sort((a, b) => getDuration(b) - getDuration(a)); + expect(longTaskSpan).toEqual( expect.objectContaining({ attributes: expect.objectContaining({ [SENTRY_OP]: { type: 'string', value: 'ui.long_task' } }), name: 'Main UI thread blocked', parent_span_id: pageloadSpan.span_id, }), ); - const start = firstUISpan.start_timestamp ?? 0; - const end = firstUISpan.end_timestamp ?? 0; - const duration = end - start; + const duration = getDuration(longTaskSpan); expect(duration).toBeGreaterThanOrEqual(0.1); - expect(duration).toBeLessThanOrEqual(0.15); + expect(duration).toBeLessThan(1); }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-static/navigation/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-static/navigation/test.ts index 376c35d25a7f..1a991d751c83 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-static/navigation/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-static/navigation/test.ts @@ -35,7 +35,8 @@ sentryTest( [SENTRY_IDLE_SPAN_FINISH_REASON]: 'cancelled', }); - // ending span after 1s but adding a margin of 0.5s to account for timing weirdness in CI to avoid flakes - expect(spanDurationSeconds).toBeLessThan(1.5); + // The navigation starts 1s after init, but init itself can run well after the time origin on slow CI machines. + // Calling `reportPageLoaded()` would only end the span after 2.5s. + expect(spanDurationSeconds).toBeLessThan(2.5); }, ); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/navigation/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/navigation/test.ts index b67276f0bcd0..308a33bfa6fb 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/navigation/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/navigation/test.ts @@ -33,7 +33,8 @@ sentryTest( [SENTRY_IDLE_SPAN_FINISH_REASON]: { type: 'string', value: 'cancelled' }, }); - // ending span after 1s but adding a margin of 0.5s to account for timing weirdness in CI to avoid flakes - expect(spanDurationSeconds).toBeLessThan(1.5); + // The navigation starts 1s after init, but init itself can run well after the time origin on slow CI machines. + // Calling `reportPageLoaded()` would only end the span after 2.5s. + expect(spanDurationSeconds).toBeLessThan(2.5); }, ); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls/test.ts index 22e494e73447..3a402ae3c2d2 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls/test.ts @@ -43,7 +43,7 @@ sentryTest('captures CLS as a streamed span with source attributes', async ({ ge const pageloadSpan = await pageloadSpanPromise; expect(clsSpan.attributes[SENTRY_OP]).toEqual({ type: 'string', value: 'ui.webvital.cls' }); - expect(clsSpan.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'auto.http.browser.cls' }); + expect(clsSpan.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'auto.ui.browser.cls' }); expect(clsSpan.attributes[SENTRY_EXCLUSIVE_TIME]).toEqual({ type: 'integer', value: 0 }); expect(clsSpan.attributes[USER_AGENT_ORIGINAL]?.value).toEqual(expect.stringContaining('Chrome')); @@ -71,7 +71,7 @@ sentryTest('captures CLS as a streamed span with source attributes', async ({ ge [ { cls: 0.05, min: 0.03, max: 0.07, source: 'body > div#content > p#partial' }, { cls: 0.21, min: 0.18, max: 0.23, source: 'body > div#content > p' }, - { cls: 0.35, min: 0.34, max: 0.36, source: 'body > div#content > p' }, + { cls: 0.35, min: 0.32, max: 0.38, source: 'body > div#content > p' }, ].forEach(({ cls, min, max, source }) => { sentryTest(`captures CLS ${cls} with its source`, async ({ getLocalTestUrl, page }) => { const url = await getLocalTestUrl({ testDir: __dirname }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts index 63f6f5ca17a6..3847ee0df51a 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts @@ -101,7 +101,7 @@ sentryTest( status: 'ok', attributes: { [SENTRY_IS_LOCALHOST]: { value: false, type: 'boolean' }, - [SENTRY_ORIGIN]: { value: 'auto.http.browser.inp', type: 'string' }, + [SENTRY_ORIGIN]: { value: 'auto.ui.browser.inp', type: 'string' }, [SENTRY_OP]: { value: UI_INTERACTION_CLICK, type: 'string' }, [UI_COMPONENT_NAME]: { value: 'NormalButton', type: 'string' }, [BROWSER_WEB_VITAL_INP_TARGET]: { value: 'body > NormalButton', type: 'string' }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts index 8cdafe4870c0..edacd3444d55 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts @@ -101,7 +101,7 @@ sentryTest( status: 'ok', attributes: { [SENTRY_IS_LOCALHOST]: { value: false, type: 'boolean' }, - [SENTRY_ORIGIN]: { value: 'auto.http.browser.inp', type: 'string' }, + [SENTRY_ORIGIN]: { value: 'auto.ui.browser.inp', type: 'string' }, [SENTRY_OP]: { value: UI_INTERACTION_CLICK, type: 'string' }, [UI_COMPONENT_NAME]: { value: 'NavigationLink', type: 'string' }, [BROWSER_WEB_VITAL_INP_TARGET]: { value: 'body > nav#navigation > NavigationLink', type: 'string' }, @@ -192,7 +192,7 @@ sentryTest( status: 'ok', attributes: { [SENTRY_IS_LOCALHOST]: { value: false, type: 'boolean' }, - [SENTRY_ORIGIN]: { value: 'auto.http.browser.inp', type: 'string' }, + [SENTRY_ORIGIN]: { value: 'auto.ui.browser.inp', type: 'string' }, [SENTRY_OP]: { value: UI_INTERACTION_CLICK, type: 'string' }, [BROWSER_WEB_VITAL_INP_TARGET]: { value: 'body > nav#navigation > NavigationLink', type: 'string' }, [SENTRY_EXCLUSIVE_TIME]: { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts index 81d264959eb5..d06da7a58912 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts @@ -102,7 +102,7 @@ sentryTest( status: 'ok', attributes: { [SENTRY_IS_LOCALHOST]: { value: false, type: 'boolean' }, - [SENTRY_ORIGIN]: { value: 'auto.http.browser.inp', type: 'string' }, + [SENTRY_ORIGIN]: { value: 'auto.ui.browser.inp', type: 'string' }, [SENTRY_OP]: { value: UI_INTERACTION_CLICK, type: 'string' }, [UI_COMPONENT_NAME]: { value: 'NormalButton', type: 'string' }, [BROWSER_WEB_VITAL_INP_TARGET]: { value: 'body > NormalButton', type: 'string' }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts index cda0412f8d4e..269c543c4dec 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts @@ -102,7 +102,7 @@ sentryTest( status: 'ok', attributes: { [SENTRY_IS_LOCALHOST]: { value: false, type: 'boolean' }, - [SENTRY_ORIGIN]: { value: 'auto.http.browser.inp', type: 'string' }, + [SENTRY_ORIGIN]: { value: 'auto.ui.browser.inp', type: 'string' }, [SENTRY_OP]: { value: UI_INTERACTION_CLICK, type: 'string' }, [UI_COMPONENT_NAME]: { value: 'NormalButton', type: 'string' }, [BROWSER_WEB_VITAL_INP_TARGET]: { value: 'body > NormalButton', type: 'string' }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts index 3510b5bb1d9b..ac0f4ea88aa4 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts @@ -43,7 +43,7 @@ sentryTest('captures INP click as a streamed span', async ({ getLocalTestUrl, pa const pageloadSpan = await pageloadSpanPromise; expect(inpSpan.attributes[SENTRY_OP]).toEqual({ type: 'string', value: UI_INTERACTION_CLICK }); - expect(inpSpan.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'auto.http.browser.inp' }); + expect(inpSpan.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'auto.ui.browser.inp' }); expect(inpSpan.attributes[USER_AGENT_ORIGINAL]?.value).toEqual(expect.stringContaining('Chrome')); // Check the INP span carries the transaction/segment name it belongs to diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp/test.ts index 263b31d96660..5753e13389c0 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp/test.ts @@ -52,7 +52,7 @@ sentryTest('captures LCP as a streamed span with element attributes', async ({ g const pageloadSpan = await pageloadSpanPromise; expect(lcpSpan.attributes[SENTRY_OP]).toEqual({ type: 'string', value: 'ui.webvital.lcp' }); - expect(lcpSpan.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'auto.http.browser.lcp' }); + expect(lcpSpan.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'auto.ui.browser.lcp' }); expect(lcpSpan.attributes[SENTRY_EXCLUSIVE_TIME]).toEqual({ type: 'integer', value: 0 }); expect(lcpSpan.attributes[USER_AGENT_ORIGINAL]?.value).toEqual(expect.stringContaining('Chrome')); diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts index c8a8bf93a1a9..c62a8b2059ec 100644 --- a/dev-packages/bun-integration-tests/node-suites/excludes.ts +++ b/dev-packages/bun-integration-tests/node-suites/excludes.ts @@ -25,6 +25,7 @@ const NO_HTTP_SERVER_SPANS = [ 'suites/tracing/httpIntegration/test.ts', 'suites/tracing/httpServerSpans-unrouted/test.ts', 'suites/tracing/ignoreSpans/**', + 'suites/tracing/managed-tunnel/test.ts', 'suites/tracing/meta-tags-twp-errors/test.ts', 'suites/tracing/meta-tags/test.ts', 'suites/tracing/requestData/test.ts', @@ -54,6 +55,7 @@ const NO_FETCH_INSTRUMENTATION = [ 'suites/tracing/requests/fetch-no-tracing-no-spans/test.ts', 'suites/tracing/requests/fetch-no-tracing/test.ts', 'suites/tracing/requests/fetch-sampled-no-active-span/test.ts', + 'suites/tracing/requests/fetch-sentry-requests/test.ts', 'suites/tracing/requests/fetch-unsampled/test.ts', 'suites/tracing/requests/traceparent/test.ts', ]; @@ -122,6 +124,7 @@ export const NO_AUTO_INSTRUMENTATION = [ 'suites/tracing/redis/test.ts', 'suites/tracing/tedious/test.ts', 'suites/tracing/together-ai/test.ts', + 'suites/tracing/together-ai/v1/test.ts', 'suites/tracing/typesafe/test.ts', 'suites/tracing/vercelai/**', ]; @@ -133,9 +136,6 @@ const NOT_TRIAGED = [ 'suites/proxy/test.ts', ]; -// The suite expects the events of `nodeSystemErrorIntegration`, which `@sentry/bun` does not include. -const NO_SYSTEM_ERROR_INTEGRATION = ['suites/system-error/test.ts']; - // The scenario configures `nativeNodeFetchIntegration`, which `@sentry/bun` does not export. const NO_NATIVE_NODE_FETCH_INTEGRATION = [ 'suites/tracing/http-client-spans/fetch-forward-request-hook/test.ts', @@ -195,7 +195,6 @@ export const SENTRY_NODE_EXCLUDE = [...NO_HTTP_SERVER_SPANS, ...NO_FETCH_INSTRUM export const SENTRY_BUN_EXCLUDE = [ ...HTTP_SERVER_OPTIONS_IGNORED, ...NO_EXPRESS_INSTRUMENTATION, - ...NO_SYSTEM_ERROR_INTEGRATION, ...NO_NATIVE_NODE_FETCH_INTEGRATION, ...FETCH_INTEGRATION_DIFFERS, ]; diff --git a/dev-packages/bun-integration-tests/package.json b/dev-packages/bun-integration-tests/package.json index 4b6fa4e3c006..56460de1d10e 100644 --- a/dev-packages/bun-integration-tests/package.json +++ b/dev-packages/bun-integration-tests/package.json @@ -23,10 +23,10 @@ "devDependencies": { "@sentry-internal/node-integration-tests": "11.9.0", "@sentry-internal/test-utils": "11.9.0", + "@sentry/core": "11.9.0", + "@types/mysql": "^2.15.27", + "@types/pg": "^8.20.0", "bun-types": "^1.2.9", "vitest": "^4.1.11" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/bundler-plugin-integration-tests/package.json b/dev-packages/bundler-plugin-integration-tests/package.json index 2d9d9b69a3d8..56f7f8040a07 100644 --- a/dev-packages/bundler-plugin-integration-tests/package.json +++ b/dev-packages/bundler-plugin-integration-tests/package.json @@ -16,10 +16,8 @@ }, "devDependencies": { "premove": "^4.0.0", + "sentry": "^0.45.0", "typescript": "~6.0.3", "vitest": "^4.1.11" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/bundler-tests/package.json b/dev-packages/bundler-tests/package.json index 1210085b32ef..467fd9325fbf 100644 --- a/dev-packages/bundler-tests/package.json +++ b/dev-packages/bundler-tests/package.json @@ -19,8 +19,5 @@ "vitest": "^4.1.11", "webpack": "^5.0.0" }, - "volta": { - "extends": "../../package.json" - }, "type": "module" } diff --git a/dev-packages/clear-cache-gh-action/package.json b/dev-packages/clear-cache-gh-action/package.json index 40ead9d8be60..0c51d7a82217 100644 --- a/dev-packages/clear-cache-gh-action/package.json +++ b/dev-packages/clear-cache-gh-action/package.json @@ -19,8 +19,5 @@ }, "devDependencies": { "eslint-plugin-regexp": "^3.1.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index 26cfa94d3c12..a81c60d026ff 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -34,15 +34,13 @@ "@cloudflare/vite-plugin": "1.34.0", "@cloudflare/workers-types": "^4.20260426.0", "@cloudflare/workers-types-v5": "npm:@cloudflare/workers-types@5.20260710.1", + "@opentelemetry/api": "^1.9.1", "@sentry-internal/test-utils": "11.9.0", - "@sentry/conventions": "0.27.0", + "@sentry/conventions": "0.29.0", "eslint-plugin-regexp": "^3.1.0", "prisma": "6.15.0", "vite": "^8.3.1", "vitest": "^4.1.11", "wrangler": "4.86.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/index.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/index.ts new file mode 100644 index 000000000000..abc4a78578af --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/index.ts @@ -0,0 +1,46 @@ +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + TEST_DURABLE_OBJECT: DurableObjectNamespace; + TAIL: Fetcher; +} + +export class TestDurableObject extends DurableObject { + async fetch(): Promise { + return new Response('OK'); + } + + async ping(): Promise { + return 'pong'; + } + + async setAlarm(): Promise { + await this.ctx.storage.setAlarm(Date.now()); + } + + async alarm(): Promise {} +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === '/invocations') { + return env.TAIL.fetch(request); + } + + const stub = env.TEST_DURABLE_OBJECT.get(env.TEST_DURABLE_OBJECT.idFromName(url.pathname)); + + if (url.pathname === '/rpc') { + return new Response(await stub.ping()); + } + + if (url.pathname === '/alarm') { + await stub.setAlarm(); + return new Response('Alarm scheduled'); + } + + return stub.fetch(request); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/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/durableobject/invocation-duration/tail.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/tail.ts new file mode 100644 index 000000000000..865d977d6a5c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/tail.ts @@ -0,0 +1,26 @@ +interface Invocation { + trigger: string; + durationMs: number; +} + +const invocations: Invocation[] = []; + +export default { + async tail(events: TraceItem[]): Promise { + // `wallTime` is always 0 in local dev, so the duration ends when the tail event arrives. + const receivedAt = Date.now(); + + for (const { event, eventTimestamp, executionModel } of events) { + if (executionModel !== 'durableObject' || !event || eventTimestamp === null) { + continue; + } + + const trigger = 'rpcMethod' in event ? event.rpcMethod : 'scheduledTime' in event ? 'alarm' : 'fetch'; + invocations.push({ trigger, durationMs: receivedAt - eventTimestamp }); + } + }, + + async fetch(): Promise { + return Response.json(invocations); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/test.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/test.ts new file mode 100644 index 000000000000..11db17ae2348 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/test.ts @@ -0,0 +1,46 @@ +import { join } from 'node:path'; +import { expect, it, vi } from 'vitest'; +import { createRunner } from '../../../runner'; + +interface Invocation { + trigger: string; + durationMs: number; +} + +// Regression test for https://github.com/getsentry/sentry-javascript/issues/25489 +// workerd keeps a Durable Object invocation open until its pending timers fire, so an SDK timer +// that is still pending when the method returns makes the invocation longer, and billed. +it('does not keep Durable Object invocations open after the method returns', async ({ signal }) => { + // The tail worker must run in the same `wrangler dev` process to receive the tail events. + const runner = createRunner(__dirname) + .withWranglerArgs('--config', join(__dirname, 'wrangler-tail.jsonc')) + .start(signal); + + await runner.makeRequest('get', '/fetch'); + await runner.makeRequest('get', '/rpc'); + await runner.makeRequest('get', '/alarm'); + + const { fetchInvocation, rpcInvocation, alarmInvocation } = await vi.waitFor( + async () => { + const invocations = (await runner.makeRequest('get', '/invocations')) ?? []; + const result = { + fetchInvocation: invocations.find(invocation => invocation.trigger === 'fetch'), + rpcInvocation: invocations.find(invocation => invocation.trigger === 'ping'), + alarmInvocation: invocations.find(invocation => invocation.trigger === 'alarm'), + }; + + expect(result.fetchInvocation).toBeDefined(); + expect(result.rpcInvocation).toBeDefined(); + expect(result.alarmInvocation).toBeDefined(); + + return result; + }, + { timeout: 10_000, interval: 100 }, + ); + + // An invocation takes a few ms locally. The limit leaves room for slow CI machines and still + // fails for a 500 ms timer. + expect(fetchInvocation?.durationMs).toBeLessThan(250); + expect(rpcInvocation?.durationMs).toBeLessThan(250); + expect(alarmInvocation?.durationMs).toBeLessThan(250); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/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/durableobject/invocation-duration/wrangler-tail.jsonc b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/wrangler-tail.jsonc new file mode 100644 index 000000000000..88d1d67db66f --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/wrangler-tail.jsonc @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "durableobject-invocation-duration-tail", + "main": "tail.ts", + "compatibility_date": "2025-06-17", +} diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/wrangler.jsonc new file mode 100644 index 000000000000..f37ce093d9ce --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/wrangler.jsonc @@ -0,0 +1,13 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "durableobject-invocation-duration", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], + "durable_objects": { + "bindings": [{ "name": "TEST_DURABLE_OBJECT", "class_name": "TestDurableObject" }], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["TestDurableObject"] }], + "services": [{ "binding": "TAIL", "service": "durableobject-invocation-duration-tail" }], + "tail_consumers": [{ "service": "durableobject-invocation-duration-tail" }], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/index.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/index.ts new file mode 100644 index 000000000000..ce625f75cd49 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/index.ts @@ -0,0 +1,41 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +// A plain worker. The `@sentry/cloudflare/vite` plugin wraps it with `withSentry` at build time, +// with the options from `instrument.server.ts`. +export default { + async fetch(request: Request): Promise { + const { pathname } = new URL(request.url); + + if (pathname === '/health') { + Sentry.captureMessage('Health check'); + return new Response('ok'); + } + + if (pathname === '/favicon.ico') { + Sentry.captureMessage('Static asset'); + return new Response('icon'); + } + + if (pathname === '/health/stream') { + // No span for this request. The message is captured after the handler returns, while the + // body streams, so it is only sent if the SDK waits for the stream to finish. + const encoder = new TextEncoder(); + const body = new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode('a')); + await new Promise(resolve => setTimeout(resolve, 50)); + Sentry.captureMessage('Streamed after handler return'); + controller.enqueue(encoder.encode('b')); + controller.close(); + }, + }); + return new Response(body, { headers: { 'content-type': 'text/event-stream' } }); + } + + return new Response('ok', { status: 201 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/instrument.server.ts new file mode 100644 index 000000000000..95122b1cba8a --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/instrument.server.ts @@ -0,0 +1,18 @@ +import { defineCloudflareOptions, httpServerIntegration } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + traceLifecycle: 'static', + integrations: [ + httpServerIntegration({ + ignoreIncomingRequests: urlPath => urlPath.startsWith('/health'), + onSpanCreated: (span, request) => { + span.setAttribute('test.on_span_created', new URL(request.url).pathname); + }, + onSpanEnd: (span, _request, response) => { + span.setAttribute('test.on_span_end', response.status); + }, + }), + ], +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/test.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/test.ts new file mode 100644 index 000000000000..35946ea9ac45 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/test.ts @@ -0,0 +1,71 @@ +import type { Envelope, Event } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +function itemOf(envelope: Envelope): { type: string; event: Event } { + const [header, event] = envelope[1][0] as [{ type: string }, Event]; + return { type: header.type, event }; +} + +function messageEvent(message: string): (envelope: Envelope) => void { + return envelope => { + const { type, event } = itemOf(envelope); + expect(type).toBe('event'); + expect(event.message).toBe(message); + }; +} + +// The `onSpanCreated` and `onSpanEnd` hooks of the worker set these attributes. +function apiTransaction(envelope: Envelope): void { + const { type, event } = itemOf(envelope); + expect(type).toBe('transaction'); + expect(event.transaction).toBe('GET /api'); + expect(event.contexts?.trace?.data).toEqual( + expect.objectContaining({ + 'test.on_span_created': '/api', + 'test.on_span_end': 201, + }), + ); +} + +// Each test makes a request without a span, then a request with one. With `failOnUnexpected`, a +// transaction for the first request fails the test, as it arrives before the second one. + +it('does not create a span for a request that ignoreIncomingRequests matches', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + .failOnUnexpected() + .expect(messageEvent('Health check')) + .expect(apiTransaction) + .start(signal); + + await runner.makeRequest('get', '/health'); + await runner.makeRequest('get', '/api'); + await runner.completed(); +}); + +it('does not create a span for a static asset request by default', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + .failOnUnexpected() + .expect(messageEvent('Static asset')) + .expect(apiTransaction) + .start(signal); + + await runner.makeRequest('get', '/favicon.ico'); + await runner.makeRequest('get', '/api'); + await runner.completed(); +}); + +it('sends events from a streamed response without a span after the stream finishes', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + .failOnUnexpected() + .expect(messageEvent('Streamed after handler return')) + .expect(apiTransaction) + .start(signal); + + expect(await runner.makeRequest('get', '/health/stream')).toBe('ab'); + await runner.makeRequest('get', '/api'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/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/integrations/http-server-spans/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/wrangler.jsonc new file mode 100644 index 000000000000..790abe9762d8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/wrangler.jsonc @@ -0,0 +1,9 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-integrations-http-server-spans", + // `main` points at the source entry. The Sentry Vite plugin builds from it, and the runner serves + // the built output. + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/kv/index.ts b/dev-packages/cloudflare-integration-tests/suites/kv/index.ts new file mode 100644 index 000000000000..64ef3ae0e0b6 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/kv/index.ts @@ -0,0 +1,55 @@ +import type { KVNamespace } from '@cloudflare/workers-types'; + +interface Env { + MY_KV: KVNamespace; +} + +export default { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === '/kv/get') { + await env.MY_KV.put('get-key', 'get-value'); + const value = await env.MY_KV.get('get-key'); + return new Response(value); + } + + if (url.pathname === '/kv/get-miss') { + const value = await env.MY_KV.get('missing-key'); + return new Response(value ?? 'miss'); + } + + if (url.pathname === '/kv/bulk-get') { + await env.MY_KV.put('bulk-a', 'a'); + await env.MY_KV.put('bulk-b', 'b'); + const values = await env.MY_KV.get(['bulk-a', 'bulk-b']); + return Response.json(Object.fromEntries(values)); + } + + if (url.pathname === '/kv/get-with-metadata') { + await env.MY_KV.put('metadata-key', 'metadata-value', { metadata: { source: 'test' } }); + const value = await env.MY_KV.getWithMetadata('metadata-key'); + return Response.json(value); + } + + if (url.pathname === '/kv/put') { + await env.MY_KV.put('put-key', 'put-value'); + return new Response('OK'); + } + + if (url.pathname === '/kv/list') { + await env.MY_KV.put('list-a', 'a'); + await env.MY_KV.put('list-b', 'b'); + await env.MY_KV.list({ prefix: 'list-' }); + return new Response('OK'); + } + + if (url.pathname === '/kv/delete') { + await env.MY_KV.put('delete-key', 'gone'); + await env.MY_KV.delete('delete-key'); + return new Response('OK'); + } + + return new Response('not found', { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/kv/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/kv/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/kv/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/kv/test.ts b/dev-packages/cloudflare-integration-tests/suites/kv/test.ts new file mode 100644 index 000000000000..9b51d4fe0509 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/kv/test.ts @@ -0,0 +1,290 @@ +import { + CACHE_HIT, + CACHE_KEY, + CACHE_OPERATION, + DB_COLLECTION_NAME, + DB_OPERATION_NAME, + DB_SYSTEM_NAME, + SENTRY_ENVIRONMENT, + SENTRY_IS_LOCALHOST, + SENTRY_OP, + SENTRY_ORIGIN, + SENTRY_SDK_NAME, + SENTRY_SDK_VERSION, + SENTRY_SEGMENT_ID, + SENTRY_SEGMENT_NAME, + SENTRY_TRACE_LIFECYCLE, + URL_PATH, +} from '@sentry/conventions/attributes'; +import { CACHE_GET, CACHE_PUT, CACHE_REMOVE } from '@sentry/conventions/op'; +import { CACHE_OPERATION_NAMES, SDK_VERSION } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../runner'; +import { getSpansFromEnvelope } from '../../spanUtils'; + +it('emits cache.get for a KV read with key and hit attributes', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + const getSpans = spans.filter(span => span.name === CACHE_GET); + + expect(getSpans).toHaveLength(1); + expect(getSpans[0]?.attributes).toEqual({ + [SENTRY_OP]: { type: 'string', value: CACHE_GET }, + [SENTRY_ORIGIN]: { type: 'string', value: 'auto.cache.cloudflare.kv' }, + [CACHE_OPERATION]: { type: 'string', value: CACHE_OPERATION_NAMES[CACHE_GET] }, + [CACHE_KEY]: { type: 'array', value: ['get-key'] }, + [CACHE_HIT]: { type: 'boolean', value: true }, + [DB_OPERATION_NAME]: { type: 'string', value: 'get' }, + [DB_COLLECTION_NAME]: { type: 'string', value: 'MY_KV' }, + [DB_SYSTEM_NAME]: { type: 'string', value: 'cloudflare-kv' }, + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'GET' }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpan?.span_id }, + [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.cloudflare' }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_IS_LOCALHOST]: { type: 'boolean', value: true }, + }); + }) + .start(signal); + + await runner.makeRequest('get', '/kv/get'); + await runner.completed(); +}); + +it('emits cache.get with cache.hit false for a KV read of a missing key', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + const getSpans = spans.filter(span => span.name === CACHE_GET); + + expect(getSpans).toHaveLength(1); + expect(getSpans[0]?.attributes).toEqual({ + [SENTRY_OP]: { type: 'string', value: CACHE_GET }, + [SENTRY_ORIGIN]: { type: 'string', value: 'auto.cache.cloudflare.kv' }, + [CACHE_OPERATION]: { type: 'string', value: CACHE_OPERATION_NAMES[CACHE_GET] }, + [CACHE_KEY]: { type: 'array', value: ['missing-key'] }, + [CACHE_HIT]: { type: 'boolean', value: false }, + [DB_OPERATION_NAME]: { type: 'string', value: 'get' }, + [DB_COLLECTION_NAME]: { type: 'string', value: 'MY_KV' }, + [DB_SYSTEM_NAME]: { type: 'string', value: 'cloudflare-kv' }, + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'GET' }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpan?.span_id }, + [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.cloudflare' }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_IS_LOCALHOST]: { type: 'boolean', value: true }, + }); + }) + .start(signal); + + await runner.makeRequest('get', '/kv/get-miss'); + await runner.completed(); +}); + +it('emits cache.get for a bulk KV read with all keys in cache.key', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + const getSpans = spans.filter(span => span.name === CACHE_GET); + + expect(getSpans).toHaveLength(1); + expect(getSpans[0]?.attributes).toEqual({ + [SENTRY_OP]: { type: 'string', value: CACHE_GET }, + [SENTRY_ORIGIN]: { type: 'string', value: 'auto.cache.cloudflare.kv' }, + [CACHE_OPERATION]: { type: 'string', value: CACHE_OPERATION_NAMES[CACHE_GET] }, + [CACHE_KEY]: { type: 'array', value: ['bulk-a', 'bulk-b'] }, + [CACHE_HIT]: { type: 'boolean', value: true }, + [DB_OPERATION_NAME]: { type: 'string', value: 'get' }, + [DB_COLLECTION_NAME]: { type: 'string', value: 'MY_KV' }, + [DB_SYSTEM_NAME]: { type: 'string', value: 'cloudflare-kv' }, + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'GET' }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpan?.span_id }, + [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.cloudflare' }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_IS_LOCALHOST]: { type: 'boolean', value: true }, + }); + }) + .start(signal); + + await runner.makeRequest('get', '/kv/bulk-get'); + await runner.completed(); +}); + +it('emits cache.get for getWithMetadata', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + const getSpans = spans.filter(span => span.name === CACHE_GET); + + expect(getSpans).toHaveLength(1); + expect(getSpans[0]?.attributes).toEqual({ + [SENTRY_OP]: { type: 'string', value: CACHE_GET }, + [SENTRY_ORIGIN]: { type: 'string', value: 'auto.cache.cloudflare.kv' }, + [CACHE_OPERATION]: { type: 'string', value: CACHE_OPERATION_NAMES[CACHE_GET] }, + [CACHE_KEY]: { type: 'array', value: ['metadata-key'] }, + [CACHE_HIT]: { type: 'boolean', value: true }, + [DB_OPERATION_NAME]: { type: 'string', value: 'getWithMetadata' }, + [DB_COLLECTION_NAME]: { type: 'string', value: 'MY_KV' }, + [DB_SYSTEM_NAME]: { type: 'string', value: 'cloudflare-kv' }, + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'GET' }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpan?.span_id }, + [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.cloudflare' }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_IS_LOCALHOST]: { type: 'boolean', value: true }, + }); + }) + .start(signal); + + await runner.makeRequest('get', '/kv/get-with-metadata'); + await runner.completed(); +}); + +it('emits cache.put for KV writes', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + const putSpans = spans.filter(span => span.name === CACHE_PUT); + + expect(putSpans).toHaveLength(1); + expect(putSpans[0]?.attributes).toEqual({ + [SENTRY_OP]: { type: 'string', value: CACHE_PUT }, + [SENTRY_ORIGIN]: { type: 'string', value: 'auto.cache.cloudflare.kv' }, + [CACHE_OPERATION]: { type: 'string', value: CACHE_OPERATION_NAMES[CACHE_PUT] }, + [CACHE_KEY]: { type: 'array', value: ['put-key'] }, + [DB_OPERATION_NAME]: { type: 'string', value: 'put' }, + [DB_COLLECTION_NAME]: { type: 'string', value: 'MY_KV' }, + [DB_SYSTEM_NAME]: { type: 'string', value: 'cloudflare-kv' }, + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'GET' }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpan?.span_id }, + [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.cloudflare' }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_IS_LOCALHOST]: { type: 'boolean', value: true }, + }); + }) + .start(signal); + + await runner.makeRequest('get', '/kv/put'); + await runner.completed(); +}); + +it('emits cache.remove for KV deletes', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + const removeSpans = spans.filter(span => span.name === CACHE_REMOVE); + + expect(removeSpans).toHaveLength(1); + expect(removeSpans[0]?.attributes).toEqual({ + [SENTRY_OP]: { type: 'string', value: CACHE_REMOVE }, + [SENTRY_ORIGIN]: { type: 'string', value: 'auto.cache.cloudflare.kv' }, + [CACHE_OPERATION]: { type: 'string', value: CACHE_OPERATION_NAMES[CACHE_REMOVE] }, + [CACHE_KEY]: { type: 'array', value: ['delete-key'] }, + [DB_OPERATION_NAME]: { type: 'string', value: 'delete' }, + [DB_COLLECTION_NAME]: { type: 'string', value: 'MY_KV' }, + [DB_SYSTEM_NAME]: { type: 'string', value: 'cloudflare-kv' }, + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'GET' }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpan?.span_id }, + [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.cloudflare' }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_IS_LOCALHOST]: { type: 'boolean', value: true }, + }); + }) + .start(signal); + + await runner.makeRequest('get', '/kv/delete'); + await runner.completed(); +}); + +it('emits cache.get without cache.key or cache.hit for KV list operations', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + const getSpans = spans.filter(span => span.name === CACHE_GET); + + expect(getSpans).toHaveLength(1); + expect(getSpans[0]?.attributes).toEqual({ + [SENTRY_OP]: { type: 'string', value: CACHE_GET }, + [SENTRY_ORIGIN]: { type: 'string', value: 'auto.cache.cloudflare.kv' }, + [CACHE_OPERATION]: { type: 'string', value: CACHE_OPERATION_NAMES[CACHE_GET] }, + [DB_OPERATION_NAME]: { type: 'string', value: 'list' }, + [DB_COLLECTION_NAME]: { type: 'string', value: 'MY_KV' }, + [DB_SYSTEM_NAME]: { type: 'string', value: 'cloudflare-kv' }, + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'GET' }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpan?.span_id }, + [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.cloudflare' }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_IS_LOCALHOST]: { type: 'boolean', value: true }, + }); + }) + .start(signal); + + await runner.makeRequest('get', '/kv/list'); + await runner.completed(); +}); + +it("emits the KV spans of each request under that request's own segment span", async ({ signal }) => { + const segmentTraceIds: string[] = []; + const runner = createRunner(__dirname) + .unordered() + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + + expect(segmentSpan?.attributes[URL_PATH]).toEqual({ type: 'string', value: '/kv/get' }); + expect(spans.filter(span => !span.is_segment)).toEqual([ + expect.objectContaining({ + name: CACHE_PUT, + trace_id: segmentSpan?.trace_id, + parent_span_id: segmentSpan?.span_id, + }), + expect.objectContaining({ + name: CACHE_GET, + trace_id: segmentSpan?.trace_id, + parent_span_id: segmentSpan?.span_id, + }), + ]); + segmentTraceIds.push(segmentSpan!.trace_id); + }) + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + + expect(segmentSpan?.attributes[URL_PATH]).toEqual({ type: 'string', value: '/kv/put' }); + expect(spans.filter(span => !span.is_segment)).toEqual([ + expect.objectContaining({ + name: CACHE_PUT, + trace_id: segmentSpan?.trace_id, + parent_span_id: segmentSpan?.span_id, + }), + ]); + segmentTraceIds.push(segmentSpan!.trace_id); + }) + .start(signal); + + await runner.makeRequest('get', '/kv/get'); + await runner.makeRequest('get', '/kv/put'); + await runner.completed(); + + expect(segmentTraceIds).toHaveLength(2); + expect(segmentTraceIds[0]).not.toBe(segmentTraceIds[1]); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/kv/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/kv/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/kv/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/kv/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/kv/wrangler.jsonc new file mode 100644 index 000000000000..b40a23adc182 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/kv/wrangler.jsonc @@ -0,0 +1,13 @@ +{ + "$schema": "../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-kv", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], + "kv_namespaces": [ + { + "binding": "MY_KV", + "id": "00000000000000000000000000000000", + }, + ], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/test.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/test.ts index 013bf552d772..12cf754e08b6 100644 --- a/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/test.ts @@ -12,7 +12,6 @@ it('should add server.address attribute to metrics when serverName is set', asyn name: 'test.counter', type: 'counter', value: 1, - span_id: expect.any(String), timestamp: expect.any(Number), trace_id: expect.any(String), attributes: { diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/index.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/index.ts new file mode 100644 index 000000000000..55fb7b4466b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/index.ts @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +// A plain worker. The `@sentry/cloudflare/vite` plugin wraps it with `withSentry` at build time, +// with the options from `instrument.server.ts`. +export default { + async fetch(): Promise { + const userId = 42; + Sentry.captureMessage(Sentry.parameterize`User ${userId} logged in`); + return new Response('ok'); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/instrument.server.ts new file mode 100644 index 000000000000..9de8aec879bb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/instrument.server.ts @@ -0,0 +1,5 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/test.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/test.ts new file mode 100644 index 000000000000..b8dc67c3e462 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/test.ts @@ -0,0 +1,15 @@ +import type { Event } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +it('parameterize from the public entry sends a message template and its values', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.logentry).toEqual({ message: 'User %s logged in', params: [42] }); + }) + .start(signal); + + await runner.makeRequest('get', '/'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/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/public-api/parameterize/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/wrangler.jsonc new file mode 100644 index 000000000000..cbfb247dd034 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/wrangler.jsonc @@ -0,0 +1,9 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-public-api-parameterize", + // `main` points at the source entry. The Sentry Vite plugin builds from it, and the runner serves + // the built output. + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts index 9c88edd7b0a3..7810f9865b5b 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts @@ -118,6 +118,7 @@ it('names D1 query spans after their query summary', async ({ signal }) => { }, ]); }) + .unordered() .start(signal); await runner.makeRequest('get', '/init'); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/test.ts index 1f8e514601a8..7898bb3feebe 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/test.ts @@ -23,7 +23,7 @@ it('instruments SQL exec operations on Durable Object storage', async ({ signal expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/exec' }); const sqlSpans = spans.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.db.cloudflare.durable_object.sql', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.db.cloudflare.durable_object_sql', ); expect(sqlSpans).toHaveLength(3); @@ -33,7 +33,7 @@ it('instruments SQL exec operations on Durable Object storage', async ({ signal name: 'CREATE TABLE users', attributes: expect.objectContaining({ [SENTRY_OP]: { type: 'string', value: 'db.query' }, - [SENTRY_ORIGIN]: { type: 'string', value: 'auto.db.cloudflare.durable_object.sql' }, + [SENTRY_ORIGIN]: { type: 'string', value: 'auto.db.cloudflare.durable_object_sql' }, 'db.system.name': { type: 'string', value: 'cloudflare-durable-object-sql' }, 'db.operation.name': { type: 'string', value: 'exec' }, 'db.query.text': { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/index.ts new file mode 100644 index 000000000000..7686a46fe7ff --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/index.ts @@ -0,0 +1,17 @@ +import { getTunnelPath } from '@sentry/core'; + +interface Env { + SENTRY_DSN: string; +} + +// A plain worker. The `@sentry/cloudflare/vite` plugin wraps it with `withSentry` at build time, +// with the options from `instrument.server.ts`. +export default { + async fetch(request: Request, env: Env): Promise { + if (new URL(request.url).pathname === '/tunnel-path') { + return new Response(getTunnelPath(env.SENTRY_DSN)); + } + + return new Response('app'); + }, +}; diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/instrument.server.ts new file mode 100644 index 000000000000..fce513e4d092 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/instrument.server.ts @@ -0,0 +1,8 @@ +import { defineCloudflareOptions, serveTunnelIntegration } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1, + // A meta-framework answers the tunnel requests, the Worker only drops their server spans. + integrations: [serveTunnelIntegration()], +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/test.ts new file mode 100644 index 000000000000..98e9570be20a --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/test.ts @@ -0,0 +1,25 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; + +function expectServerSpanAt(path: string) { + return (envelope: Parameters[0]) => { + const serverSpans = getSpansFromEnvelope(envelope).filter(span => getSpanOp(span) === 'http.server'); + + expect(serverSpans.map(span => span.attributes['url.path']?.value)).toEqual([path]); + }; +} + +it('drops the server span of managed tunnel requests', async ({ signal }) => { + // Envelopes are expected in order, so a span of the tunnel request would arrive before the last one. + const runner = createRunner(__dirname) + .expect(expectServerSpanAt('/tunnel-path')) + .expect(expectServerSpanAt('/after-tunnel')) + .start(signal); + + const tunnelPath = await runner.makeRequest('get', '/tunnel-path'); + expect(await runner.makeRequest('post', tunnelPath!, { data: 'envelope' })).toBe('app'); + await runner.makeRequest('get', '/after-tunnel'); + + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/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/tracing/managed-tunnel/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/wrangler.jsonc new file mode 100644 index 000000000000..d339482bb5b1 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/wrangler.jsonc @@ -0,0 +1,9 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "managed-tunnel", + // `main` points at the source entry. The Sentry Vite plugin builds from it, and the runner serves + // the built output. + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts index 5f793c2c2e54..b4769bda5b9f 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts @@ -51,6 +51,18 @@ export default Sentry.withSentry( return new Response(JSON.stringify(result)); } + if (url.pathname === '/extract-clairic') { + const result = await ai.run('@cf/cloudflare/clairic', { + model: 'clairic', + state: 'Invoice INV-2291 from Northwind Supply. Total due: $1,080.00.', + fields: { + invoice_number: { type: 'text' }, + total: { type: 'number', decimals: 2, unit: 'USD', instructions: 'Amount due' }, + }, + }); + return new Response(JSON.stringify(result)); + } + if (url.pathname === '/stream') { const stream = (await ai.run('@cf/meta/llama-3.1-8b-instruct', { messages: [{ role: 'user', content: 'What is the capital of France?' }], diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts index 0d7e599cca52..0b69b24a63ed 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts @@ -43,6 +43,18 @@ export class MockAi { }; } + if (model === '@cf/cloudflare/clairic') { + return { + model: 'clairic', + answers: { + invoice_number: { type: 'text', span: [8, 16], confidence: 1, value: 'INV-2291' }, + total: { type: 'number', confidence: 0.99, value: 1080 }, + }, + truncated: false, + usage: { state_tokens: 30, schema_tokens: 20, input_tokens: 50, output_tokens: 4, passes: 1 }, + }; + } + if (inputs?.stream === true) { return createSseStream([ '{"response":"The capital "}', diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts index aca3281b19c2..18d5f5a72543 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts @@ -21,7 +21,7 @@ import { SENTRY_OP, SENTRY_ORIGIN, } from '@sentry/conventions/attributes'; -import { GEN_AI_DECIDE } from '@sentry/conventions/op'; +import { GEN_AI_DECIDE, GEN_AI_EXTRACT } from '@sentry/conventions/op'; import { SDK_VERSION } from '@sentry/core'; import { expect, it } from 'vitest'; import { GEN_AI_REQUEST_STREAM_ATTRIBUTE } from '../../../../../packages/server-utils/src/ai/core/gen-ai-attributes'; @@ -230,6 +230,60 @@ it('traces a Clef evaluation like a TypeSafe Jev evaluation', async ({ signal }) await runner.completed(); }); +it('traces a Clairic extraction', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + + const genAiSpans = spans.filter(span => getSpanOp(span)?.startsWith('gen_ai.')); + expect(genAiSpans).toHaveLength(1); + + expect(genAiSpans[0]).toEqual( + expect.objectContaining({ + name: 'extract @cf/cloudflare/clairic', + status: 'ok', + is_segment: false, + attributes: { + [SENTRY_ORIGIN]: { value: 'auto.ai.cloudflare.workers_ai', type: 'string' }, + [SENTRY_OP]: { value: GEN_AI_EXTRACT, type: 'string' }, + [GEN_AI_PROVIDER_NAME]: { value: 'cloudflare.workers_ai', type: 'string' }, + [GEN_AI_OPERATION_NAME]: { value: 'extract', type: 'string' }, + [GEN_AI_REQUEST_MODEL]: { value: '@cf/cloudflare/clairic', type: 'string' }, + [GEN_AI_RESPONSE_MODEL]: { value: 'clairic', type: 'string' }, + [GEN_AI_USAGE_INPUT_TOKENS]: { value: 50, type: 'integer' }, + [GEN_AI_USAGE_OUTPUT_TOKENS]: { value: 4, type: 'integer' }, + [GEN_AI_USAGE_TOTAL_TOKENS]: { value: 54, type: 'integer' }, + // collect only output messages + [GEN_AI_OUTPUT_MESSAGES]: { + type: 'string', + value: JSON.stringify([ + { + answers: { + invoice_number: { type: 'text', span: [8, 16], confidence: 1, value: 'INV-2291' }, + total: { type: 'number', confidence: 0.99, value: 1080 }, + }, + truncated: false, + }, + ]), + }, + [SENTRY_IS_LOCALHOST]: { value: true, type: 'boolean' }, + [SENTRY_TRACE_LIFECYCLE]: { value: 'stream', type: 'string' }, + [SENTRY_SEGMENT_NAME]: { value: segmentSpan!.name, type: 'string' }, + [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, + [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, + [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, + [SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, + }, + }), + ); + }) + .start(signal); + await runner.makeRequest('get', '/extract-clairic'); + await runner.completed(); +}); + // The Workers AI integration deliberately does not call `captureException` itself. // When a `run` call fails, the error must bubble up out of the fetch handler and be // reported by the top-level Cloudflare instrumentation instead — so it shows up in diff --git a/dev-packages/cloudflare-integration-tests/suites/tunnel/index.ts b/dev-packages/cloudflare-integration-tests/suites/tunnel/index.ts new file mode 100644 index 000000000000..64380031b485 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tunnel/index.ts @@ -0,0 +1,32 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +const handler: ExportedHandler = Sentry.withSentry((env: Env) => ({ dsn: env.SENTRY_DSN }), { + async fetch(request, env) { + const { pathname } = new URL(request.url); + if (pathname === '/dsn') { + return new Response(env.SENTRY_DSN); + } + if (pathname === '/done') { + Sentry.captureMessage('done'); + return new Response('done'); + } + + return (await Sentry.handleTunnelRequestIfMatched(request)) ?? new Response('app'); + }, +}); + +export default { + async fetch(request, env, ctx) { + // There's no client before `withSentry`, so the tunnel has to be named. + const tunnelResponse = Sentry.handleTunnelRequestIfMatched(request, { + allowedDsns: [env.SENTRY_DSN], + basePath: '/outside', + }); + + return tunnelResponse ?? handler.fetch!(request, env, ctx); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/tunnel/test.ts b/dev-packages/cloudflare-integration-tests/suites/tunnel/test.ts new file mode 100644 index 000000000000..245abc4600f8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tunnel/test.ts @@ -0,0 +1,43 @@ +import type { Envelope, Event } from '@sentry/core'; +import { createEnvelope, getTunnelPath, serializeEnvelope, uuid4 } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../runner'; + +/** + * An event envelope as the browser SDK sends it through the tunnel, for the worker's DSN. + */ +function browserEnvelope(dsn: string, message: string): string { + const eventId = uuid4(); + + return serializeEnvelope( + createEnvelope({ dsn, event_id: eventId }, [[{ type: 'event' }, { event_id: eventId, message }]]), + ) as string; +} + +/** + * Expects an envelope whose event has this message. + */ +function eventWithMessage(message: string): (envelope: Envelope) => void { + return envelope => { + expect((envelope[1][0]?.[1] as Event).message).toBe(message); + }; +} + +it('forwards with handleTunnelRequestIfMatched inside withSentry, and outside it with named options', async ({ + signal, +}) => { + const runner = createRunner(__dirname) + .expect(eventWithMessage('inside withSentry')) + .expect(eventWithMessage('outside withSentry')) + .expect(eventWithMessage('done')) + .start(signal); + const dsn = (await runner.makeRequest('get', '/dsn'))!; + const tunnelPath = getTunnelPath(dsn)!; + + await runner.makeRequest('post', tunnelPath, { data: browserEnvelope(dsn, 'inside withSentry') }); + await runner.makeRequest('post', `/outside${tunnelPath}`, { data: browserEnvelope(dsn, 'outside withSentry') }); + expect(await runner.makeRequest('post', '/api', { data: browserEnvelope(dsn, 'not a tunnel') })).toBe('app'); + + await runner.makeRequest('get', '/done'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tunnel/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tunnel/wrangler.jsonc new file mode 100644 index 000000000000..d6be01281f0c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tunnel/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/index.ts new file mode 100644 index 000000000000..42d2b68be39d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/index.ts @@ -0,0 +1,33 @@ +import OpenAI from 'openai'; + +// Return a canned response so the `openai` SDK runs on workerd without hitting the network. +const mockFetch: typeof fetch = async () => + new Response( + JSON.stringify({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model: 'gpt-3.5-turbo', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Hello from OpenAI!' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 15, total_tokens: 25 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + +export default { + async fetch(_request, _env, _ctx) { + // Not wrapped with `instrumentOpenAiClient`: the vite plugin instruments the `openai` package. + const client = new OpenAI({ apiKey: 'mock-api-key', fetch: mockFetch }); + await client.chat.completions.create({ + model: 'gpt-3.5-turbo', + messages: [{ role: 'user', content: 'What is the capital of France?' }], + }); + return new Response('ok'); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/instrument.server.ts new file mode 100644 index 000000000000..5bde10c05ba7 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/instrument.server.ts @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.defineCloudflareOptions((env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1, + // `dataCollection` records inputs by default. The integration options must win over it. + integrations: [Sentry.openAIIntegration({ recordInputs: false })], +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/test.ts new file mode 100644 index 000000000000..8aab877f22f8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/test.ts @@ -0,0 +1,25 @@ +import { GEN_AI_INPUT_MESSAGES, GEN_AI_RESPONSE_TEXT, SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; +import { GEN_AI_CHAT } from '@sentry/conventions/op'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../../runner'; + +it('applies the options of an openAIIntegration the user configured', async ({ signal }) => { + const runner = createRunner(__dirname).ignore('event').start(signal); + + // The segment span and the gen_ai child. The segment can arrive before the child's envelope. + const spansPromise = runner.collectStreamedSpans(spansOfTrace => spansOfTrace.length === 2); + + await runner.makeRequest('get', '/'); + + const genAiSpans = (await spansPromise).filter(span => !span.is_segment); + expect(genAiSpans).toHaveLength(1); + const attributes = genAiSpans[0]?.attributes; + + // The vite plugin instrumented `openai`, so the channel integration created the span. + expect(attributes?.[SENTRY_OP]).toEqual({ type: 'string', value: GEN_AI_CHAT }); + expect(attributes?.[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'auto.ai.openai' }); + // `recordInputs: false` from the configured integration wins over `dataCollection`, which records + // inputs by default. Outputs keep the default. + expect(attributes?.[GEN_AI_INPUT_MESSAGES]).toBeUndefined(); + expect(attributes?.[GEN_AI_RESPONSE_TEXT]).toEqual({ type: 'string', value: '["Hello from OpenAI!"]' }); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/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/vite/diagnostics-channel/openai-options/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/wrangler.jsonc new file mode 100644 index 000000000000..4c475b2fe276 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "openai-options-worker", + "compatibility_date": "2026-04-26", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/test.ts index 3e886958978f..2eac8c531796 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/test.ts @@ -27,7 +27,7 @@ it('captures Vercel AI v6 spans via @sentry/cloudflare vercelAIIntegration', asy status: 'ok', attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'gen_ai.invoke_agent' }, - 'sentry.origin': { type: 'string', value: 'auto.vercelai.channel' }, + 'sentry.origin': { type: 'string', value: 'auto.ai.vercelai' }, [GEN_AI_OPERATION_NAME]: { type: 'string', value: 'invoke_agent' }, [GEN_AI_USAGE_INPUT_TOKENS]: { type: 'integer', value: 10 }, [GEN_AI_USAGE_OUTPUT_TOKENS]: { type: 'integer', value: 20 }, @@ -40,7 +40,7 @@ it('captures Vercel AI v6 spans via @sentry/cloudflare vercelAIIntegration', asy status: 'ok', attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'gen_ai.generate_content' }, - 'sentry.origin': { type: 'string', value: 'auto.vercelai.channel' }, + 'sentry.origin': { type: 'string', value: 'auto.ai.vercelai' }, [GEN_AI_OPERATION_NAME]: { type: 'string', value: 'generate_content' }, }), }), diff --git a/dev-packages/deno-integration-tests/node-suites/excludes.ts b/dev-packages/deno-integration-tests/node-suites/excludes.ts index a6ad2a290f16..b93a8ff63a65 100644 --- a/dev-packages/deno-integration-tests/node-suites/excludes.ts +++ b/dev-packages/deno-integration-tests/node-suites/excludes.ts @@ -26,6 +26,7 @@ const NO_FETCH_INSTRUMENTATION = [ 'suites/tracing/requests/fetch-no-tracing-no-spans/test.ts', 'suites/tracing/requests/fetch-no-tracing/test.ts', 'suites/tracing/requests/fetch-sampled-no-active-span/test.ts', + 'suites/tracing/requests/fetch-sentry-requests/test.ts', 'suites/tracing/requests/fetch-unsampled/test.ts', 'suites/tracing/requests/traceparent/test.ts', 'suites/tracing/sample-rand-propagation/test.ts', diff --git a/dev-packages/deno-integration-tests/package.json b/dev-packages/deno-integration-tests/package.json index 5ed4d2f82b65..58809ae6896c 100644 --- a/dev-packages/deno-integration-tests/package.json +++ b/dev-packages/deno-integration-tests/package.json @@ -23,9 +23,7 @@ }, "devDependencies": { "@sentry-internal/node-integration-tests": "11.9.0", + "@sentry/conventions": "^0.29.0", "vitest": "^4.1.11" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/deno-integration-tests/suites/deno-serve-managed-tunnel/test.ts b/dev-packages/deno-integration-tests/suites/deno-serve-managed-tunnel/test.ts new file mode 100644 index 000000000000..4e67982d7e47 --- /dev/null +++ b/dev-packages/deno-integration-tests/suites/deno-serve-managed-tunnel/test.ts @@ -0,0 +1,49 @@ +// + +import { getTunnelPath } from '@sentry/core'; +import { flush, init, serveTunnelIntegration } from '@sentry/deno'; +import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; + +const DSN = 'https://username@domain/123'; + +Deno.test('Deno.serve drops the server span of managed tunnel requests', async () => { + resetGlobals(); + const sink = spanSink(); + + init({ + dsn: DSN, + tracesSampleRate: 1, + // A meta-framework answers the tunnel requests, Deno only drops their server spans. + integrations: [serveTunnelIntegration()], + transport: sink.transport, + }); + + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined; + const listening = new Promise(resolve => (onListen = resolve)); + const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, () => new Response('app')); + await listening; + + try { + const baseUrl = `http://localhost:${server.addr.port}`; + const anchorSpanPromise = withTimeout( + sink.waitFor(span => getSpanOp(span) === 'http.server' && span.attributes['url.path']?.value === '/after-tunnel'), + 5_000, + 'segment span for /after-tunnel', + ); + + await (await fetch(`${baseUrl}${getTunnelPath(DSN)}`, { method: 'POST', body: 'envelope' })).text(); + await (await fetch(`${baseUrl}/after-tunnel`)).text(); + + await flush(); + await anchorSpanPromise; + assertEquals( + sink.spans.filter(span => getSpanOp(span) === 'http.server').map(span => span.attributes['url.path']?.value), + ['/after-tunnel'], + ); + } finally { + abortController.abort(); + await server.finished; + } +}); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-amqplib/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-amqplib/test.ts index 9c1031ed9ee3..4514b59d377c 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-amqplib/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-amqplib/test.ts @@ -65,5 +65,5 @@ Deno.test('amqplib instrumentation: orchestrion:amqplib:publish channel produces assertEquals(publishSpan.name, 'send my-exchange'); assertEquals(publishSpan.attributes['messaging.destination.name']?.value, 'my-exchange'); assertEquals(publishSpan.attributes['messaging.system']?.value, 'rabbitmq'); - assertEquals(publishSpan.attributes['sentry.origin']?.value, 'auto.amqplib.publisher'); + assertEquals(publishSpan.attributes['sentry.origin']?.value, 'auto.queue.amqplib.publisher'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-aws/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-aws/test.ts index 1beb142f3313..b040db2da39d 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-aws/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-aws/test.ts @@ -88,5 +88,5 @@ Deno.test('aws-sdk instrumentation: orchestrion @smithy/smithy-client:send chann assertEquals(awsSpan.attributes['rpc.service']?.value, 'CloudWatch'); assertEquals(awsSpan.attributes['rpc.method']?.value, 'DescribeAlarms'); assertEquals(awsSpan.attributes['cloud.region']?.value, 'us-east-1'); - assertEquals(awsSpan.attributes['sentry.origin']?.value, 'auto.aws.aws_sdk'); + assertEquals(awsSpan.attributes['sentry.origin']?.value, 'auto.rpc.aws_sdk'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-firebase/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-firebase/test.ts index 4f6440b8a677..cb131997dca9 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-firebase/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-firebase/test.ts @@ -70,5 +70,5 @@ Deno.test('firebase instrumentation: orchestrion @firebase/firestore:add-doc cha assertEquals(fsSpan.attributes['db.namespace']?.value, '[DEFAULT]'); assertEquals(fsSpan.attributes['db.system.name']?.value, 'firebase.firestore'); assertEquals(fsSpan.attributes['firebase.firestore.options.projectId']?.value, 'demo-project'); - assertEquals(fsSpan.attributes['sentry.origin']?.value, 'auto.firebase.firestore'); + assertEquals(fsSpan.attributes['sentry.origin']?.value, 'auto.db.firebase.firestore'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-kafkajs/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-kafkajs/test.ts index 0265468722a3..694426b326f0 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-kafkajs/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-kafkajs/test.ts @@ -57,5 +57,5 @@ Deno.test('kafkajs instrumentation: orchestrion:kafkajs:send_batch channel produ assertEquals(kafkaSpan.name, 'send my-topic'); assertEquals(kafkaSpan.attributes['messaging.system']?.value, 'kafka'); assertEquals(kafkaSpan.attributes['messaging.destination.name']?.value, 'my-topic'); - assertEquals(kafkaSpan.attributes['sentry.origin']?.value, 'auto.kafkajs.producer'); + assertEquals(kafkaSpan.attributes['sentry.origin']?.value, 'auto.queue.kafkajs.producer'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-vercel-ai/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-vercel-ai/test.ts index fdd20fd3239d..fbc0658a6b51 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-vercel-ai/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-vercel-ai/test.ts @@ -66,5 +66,5 @@ Deno.test('vercel-ai instrumentation: orchestrion:ai:generateText channel produc assertEquals(aiSpan.attributes['gen_ai.request.model']?.value, 'gpt-4o'); assertEquals(aiSpan.attributes['vercel.ai.operationId']?.value, 'ai.generateText'); assertEquals(aiSpan.attributes['gen_ai.usage.total_tokens']?.value, 15); - assertEquals(aiSpan.attributes['sentry.origin']?.value, 'auto.vercelai.channel'); + assertEquals(aiSpan.attributes['sentry.origin']?.value, 'auto.ai.vercelai'); }); diff --git a/dev-packages/deno-integration-tests/suites/parameterize/test.ts b/dev-packages/deno-integration-tests/suites/parameterize/test.ts new file mode 100644 index 000000000000..16272b3db672 --- /dev/null +++ b/dev-packages/deno-integration-tests/suites/parameterize/test.ts @@ -0,0 +1,25 @@ +// + +import * as Sentry from '@sentry/deno'; +import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; +import { errorSink, resetGlobals, withTimeout } from '../../src/index.ts'; + +Deno.test('parameterize from the public entry sends a message template and its values', async () => { + resetGlobals(); + const sink = errorSink(); + + Sentry.init({ + dsn: 'https://username@domain/123', + beforeSend: sink.beforeSend, + }); + + const userId = 42; + Sentry.captureMessage(Sentry.parameterize`User ${userId} logged in`); + + const event = await withTimeout( + sink.waitFor(e => e.logentry?.message === 'User %s logged in'), + 5_000, + 'parameterized message event', + ); + assertEquals(event.logentry, { message: 'User %s logged in', params: [42] }); +}); diff --git a/dev-packages/e2e-tests/README.md b/dev-packages/e2e-tests/README.md index 971ba3057c10..ce769d3c87f8 100644 --- a/dev-packages/e2e-tests/README.md +++ b/dev-packages/e2e-tests/README.md @@ -131,6 +131,12 @@ Apps use the pnpm version of `packageManager` in the root `package.json`. To use `package.json` (for example `"packageManager": "pnpm@10.34.6"`), and use the settings of that version in its `pnpm-workspace.yaml`. pnpm then switches to that version, locally and in CI. The lowest supported version is pnpm 10. +Apps use the Node.js version of `devEngines.runtime` in the root `package.json`. To use another version, set +`devEngines.runtime` in the app's `package.json` (for example `{ "name": "node", "version": "24.15.0", "onFail": "warn" }`). +Because the apps run in a temporary directory without the repo's `package.json` files above it, the copy step writes the +app's Node.js and pnpm versions into the copied `package.json`. pnpm's `node` and `actions/setup-node` in CI read them +from there. + ### Test apps that deploy a real Cloudflare Worker Apps like `cloudflare-workers-send-to-sentry` deploy the built app as a real Worker and check what arrives in Sentry: @@ -280,6 +286,10 @@ For example, if something is changed in the browser package, only E2E test apps You can add additional information about the test (e.g. canary versions, optional in CI) by adding `sentryTest` in the `package.json` of a test application. +The required E2E jobs run on 2 vCPUs (`namespace-profile-sentry-javascript-small`). If an app's job takes longer than +about 90 seconds, set `"sentryTest": { "runner": "namespace-profile-sentry-javascript" }` to run it and its variants on +4 vCPUs. + If you add Sentry dependencies to your test application, set the dependency versions to `latest || *` in order for it to work with both regular and prerelease versions: diff --git a/dev-packages/e2e-tests/lib/copyToTemp.mjs b/dev-packages/e2e-tests/lib/copyToTemp.mjs index ae7f5e04d5fe..269c95f9c736 100644 --- a/dev-packages/e2e-tests/lib/copyToTemp.mjs +++ b/dev-packages/e2e-tests/lib/copyToTemp.mjs @@ -1,11 +1,14 @@ // @ts-check /* eslint-disable no-console */ -import { readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { cp } from 'node:fs/promises'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, '../../..'); + +/** @typedef {{ name: string; version: string; onFail?: string }} RuntimeEntry */ /** * @param {string} originalPath @@ -16,15 +19,16 @@ export async function copyToTemp(originalPath, tmpDirPath) { // copy files to tmp dir await cp(originalPath, tmpDirPath, { recursive: true }); - fixPackageJson(tmpDirPath); + fixPackageJson(originalPath, tmpDirPath); } /** + * @param {string} originalPath * @param {string} cwd */ -function fixPackageJson(cwd) { +function fixPackageJson(originalPath, cwd) { const packageJsonPath = join(cwd, 'package.json'); - /** @type {{ dependencies?: Record; devDependencies?: Record; volta?: Record }} */ + /** @type {{ dependencies?: Record; devDependencies?: Record; devEngines?: { runtime?: RuntimeEntry | RuntimeEntry[] } & Record; packageManager?: string }} */ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); // 1. Fix file dependencies @@ -35,25 +39,46 @@ function fixPackageJson(cwd) { fixFileLinkDependencies(packageJson.devDependencies); } - // 2. Fix volta extends - if (!packageJson.volta) { - throw new Error("No volta config found, please add one to the test app's package.json!"); + // 2. Pin node and pnpm. The tmp dir has no parent package.json to inherit them from. + const { nodeRuntime, packageManager } = resolveToolPins(originalPath); + if (!nodeRuntime || !packageManager) { + throw new Error(`Could not resolve the node and pnpm versions for ${originalPath}.`); } - - if (typeof packageJson.volta.extends === 'string') { - const extendsPath = packageJson.volta.extends; - // We add a virtual dir to ensure that the relative depth is consistent - // dirPath is relative to ./../test-applications/xxx - const newPath = resolve(__dirname, 'virtual-dir/', extendsPath); - packageJson.volta.extends = newPath; - console.log(`Fixed volta.extends to ${newPath}`); - } else { - console.log('No volta.extends found'); + const ownRuntimes = [packageJson.devEngines?.runtime].flat().filter(runtime => runtime !== undefined); + if (!ownRuntimes.some(runtime => runtime.name === 'node')) { + const runtime = ownRuntimes.length ? [...ownRuntimes, nodeRuntime] : nodeRuntime; + packageJson.devEngines = { ...packageJson.devEngines, runtime }; } + packageJson.packageManager = packageManager; + console.log(`Pinned node ${nodeRuntime.version} and ${packageManager}`); writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); } +/** + * The nearest `devEngines.runtime` node entry and `packageManager` between the app and the repo root. + * @param {string} appPath + * @returns {{ nodeRuntime?: RuntimeEntry; packageManager?: string }} + */ +function resolveToolPins(appPath) { + /** @type {RuntimeEntry | undefined} */ + let nodeRuntime; + /** @type {string | undefined} */ + let packageManager; + + for (let dir = resolve(appPath); ; dir = dirname(dir)) { + const packageJsonPath = join(dir, 'package.json'); + if (existsSync(packageJsonPath)) { + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + nodeRuntime ??= [packageJson.devEngines?.runtime].flat().find(runtime => runtime?.name === 'node'); + packageManager ??= packageJson.packageManager; + } + if (dir === REPO_ROOT || dir === dirname(dir)) { + return { nodeRuntime, packageManager }; + } + } +} + /** * Exported for pnpmOverrides as well * @param {Record} dependencyObj diff --git a/dev-packages/e2e-tests/lib/getTestMatrix.mjs b/dev-packages/e2e-tests/lib/getTestMatrix.mjs index bae7c3262787..cec2cb530828 100644 --- a/dev-packages/e2e-tests/lib/getTestMatrix.mjs +++ b/dev-packages/e2e-tests/lib/getTestMatrix.mjs @@ -72,6 +72,8 @@ function addIncludesForTestApp(testApp, includes, { optionalMode }) { const shouldSkip = packageJson.sentryTest?.skip || false; const isOptional = packageJson.sentryTest?.optional || false; const variants = (optionalMode ? packageJson.sentryTest?.optionalVariants : packageJson.sentryTest?.variants) || []; + const runner = packageJson.sentryTest?.runner; + const baseInclude = { 'test-application': testApp, ...(runner ? { runner } : {}) }; if (shouldSkip) { return; @@ -79,9 +81,7 @@ function addIncludesForTestApp(testApp, includes, { optionalMode }) { // Add the basic test-application itself, if it is in the current mode if (optionalMode === isOptional) { - includes.push({ - 'test-application': testApp, - }); + includes.push(baseInclude); } variants.forEach(variant => { @@ -94,7 +94,7 @@ function addIncludesForTestApp(testApp, includes, { optionalMode }) { // Don't leak the `skip` flag into the matrix include. const { skip: _skip, ...variantConfig } = variant; includes.push({ - 'test-application': testApp, + ...baseInclude, ...variantConfig, }); }); diff --git a/dev-packages/e2e-tests/package.json b/dev-packages/e2e-tests/package.json index c5e2662d08d6..ae64e8f1e670 100644 --- a/dev-packages/e2e-tests/package.json +++ b/dev-packages/e2e-tests/package.json @@ -21,8 +21,5 @@ "devDependencies": { "eslint-plugin-regexp": "^3.1.0", "rimraf": "^6.1.3" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/run.mjs b/dev-packages/e2e-tests/run.mjs index 0125ddd89179..d8ed9865b2f8 100644 --- a/dev-packages/e2e-tests/run.mjs +++ b/dev-packages/e2e-tests/run.mjs @@ -1,10 +1,10 @@ // @ts-check /* eslint-disable no-console */ -import { execFileSync, spawn } from 'node:child_process'; +import { spawn } from 'node:child_process'; import { existsSync, readdirSync } from 'node:fs'; import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { delimiter, dirname, join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { copyToTemp } from './lib/copyToTemp.mjs'; import { addPnpmOverrides } from './lib/pnpmOverrides.mjs'; @@ -127,25 +127,6 @@ async function getVariantBuildCommand(packageJsonPath, variantLabel, testAppPath }; } -/** - * The directory of the node version that the app pins. CI uses the same pin (see `node-version-file` in - * .github/workflows/build.yml). Only node comes from Volta: Volta cannot run pnpm 12 - * (https://github.com/volta-cli/volta/issues/2114), so the apps use the pnpm version that runs this script. - * @param {string} cwd - * @returns {string | undefined} - */ -function getPinnedNodeDir(cwd) { - try { - return dirname(execFileSync('volta', ['run', 'node', '-p', 'process.execPath'], { cwd, encoding: 'utf8' }).trim()); - } catch (error) { - if (/** @type {NodeJS.ErrnoException} */ (error).code !== 'ENOENT') { - throw error; - } - console.warn(`Volta is not installed, so ${cwd} runs on node ${process.version} instead of the version it pins.`); - return undefined; - } -} - async function run() { // Load environment variables from .env file locally const envFile = join(__dirname, '.env'); @@ -274,12 +255,10 @@ async function run() { applyRuntimeFiles(tmpDirPath, runtime); } - const nodeDir = getPinnedNodeDir(cwd); const appEnv = { ...env, SENTRY_CONFIG_DIR: sentryConfigDir, ...(runtime ? { RUNTIME: runtime } : {}), - ...(nodeDir ? { PATH: `${nodeDir}${delimiter}${process.env.PATH}` } : {}), }; console.log(`Building ${testLabel} in ${tmpDirPath}...`); diff --git a/dev-packages/e2e-tests/test-applications/angular-17/package.json b/dev-packages/e2e-tests/test-applications/angular-17/package.json index a109fa77a630..3dfb8de63f42 100644 --- a/dev-packages/e2e-tests/test-applications/angular-17/package.json +++ b/dev-packages/e2e-tests/test-applications/angular-17/package.json @@ -44,8 +44,5 @@ "karma-jasmine": "~5.1.0", "karma-jasmine-html-reporter": "~2.1.0", "typescript": "~5.3.2" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/angular-18/package.json b/dev-packages/e2e-tests/test-applications/angular-18/package.json index efb5e034a92f..a18c5e59adea 100644 --- a/dev-packages/e2e-tests/test-applications/angular-18/package.json +++ b/dev-packages/e2e-tests/test-applications/angular-18/package.json @@ -44,8 +44,5 @@ "karma-jasmine": "~5.1.0", "karma-jasmine-html-reporter": "~2.1.0", "typescript": "~5.4.5" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/angular-19/package.json b/dev-packages/e2e-tests/test-applications/angular-19/package.json index b9c60d6042cc..c4d95a31e2cf 100644 --- a/dev-packages/e2e-tests/test-applications/angular-19/package.json +++ b/dev-packages/e2e-tests/test-applications/angular-19/package.json @@ -36,7 +36,7 @@ "@angular/cli": "^19.0.0", "@angular/compiler-cli": "^19.0.0", "@playwright/test": "1.64.1", - "@sentry/conventions": "0.27.0", + "@sentry/conventions": "0.29.0", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "@sentry/core": "file:../../packed/sentry-core-packed.tgz", "@types/jasmine": "~5.1.0", @@ -49,9 +49,6 @@ "karma-jasmine-html-reporter": "~2.1.0", "typescript": "~5.6.2" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "variants": [ { diff --git a/dev-packages/e2e-tests/test-applications/angular-20/package.json b/dev-packages/e2e-tests/test-applications/angular-20/package.json index fd5a9d331d05..3f9606b689de 100644 --- a/dev-packages/e2e-tests/test-applications/angular-20/package.json +++ b/dev-packages/e2e-tests/test-applications/angular-20/package.json @@ -44,8 +44,5 @@ "karma-jasmine": "~5.1.0", "karma-jasmine-html-reporter": "~2.1.0", "typescript": "~5.9.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/angular-21/package.json b/dev-packages/e2e-tests/test-applications/angular-21/package.json index d628acdd6efe..60e53772f091 100644 --- a/dev-packages/e2e-tests/test-applications/angular-21/package.json +++ b/dev-packages/e2e-tests/test-applications/angular-21/package.json @@ -46,9 +46,6 @@ "karma-jasmine-html-reporter": "~2.1.0", "typescript": "~6.0.0" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "optionalVariants": [ { diff --git a/dev-packages/e2e-tests/test-applications/angular-22/package.json b/dev-packages/e2e-tests/test-applications/angular-22/package.json index cb1d2093b904..a66c7252ef21 100644 --- a/dev-packages/e2e-tests/test-applications/angular-22/package.json +++ b/dev-packages/e2e-tests/test-applications/angular-22/package.json @@ -46,9 +46,6 @@ "karma-jasmine-html-reporter": "~2.1.0", "typescript": "~6.0.0" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "optionalVariants": [ { diff --git a/dev-packages/e2e-tests/test-applications/astro-4/package.json b/dev-packages/e2e-tests/test-applications/astro-4/package.json index e57cdfb9360a..5aad67f65d47 100644 --- a/dev-packages/e2e-tests/test-applications/astro-4/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-4/package.json @@ -23,8 +23,5 @@ }, "devDependencies": { "@astrojs/internal-helpers": "^0.4.1" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/package.json b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/package.json index c340538766c1..f24bd3abf58e 100644 --- a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/package.json @@ -19,8 +19,5 @@ }, "devDependencies": { "wrangler": "^4.63.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/astro-5/package.json b/dev-packages/e2e-tests/test-applications/astro-5/package.json index b7169dbb341c..f7398ced6d2f 100644 --- a/dev-packages/e2e-tests/test-applications/astro-5/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-5/package.json @@ -18,8 +18,5 @@ "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "@sentry/astro": "file:../../packed/sentry-astro-packed.tgz", "astro": "^5.0.3" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/astro.config.mjs b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/astro.config.mjs index 0894958462b2..4a09ea6bd88e 100644 --- a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/astro.config.mjs +++ b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/astro.config.mjs @@ -4,8 +4,13 @@ import { defineConfig } from 'astro/config'; import cloudflare from '@astrojs/cloudflare'; import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +// The tunnel variant serves the app under a base path with trailing slashes to cover that the managed tunnel +// follows both on Workers too. +const tunnelVariant = !!process.env.PUBLIC_E2E_TEST_TUNNEL; + // https://astro.build/config export default defineConfig({ + ...(tunnelVariant && { base: '/docs', trailingSlash: 'always' }), integrations: [ sentry({ debug: true, diff --git a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/instrument.server.js b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/instrument.server.js index eeb325d68c3f..fd486a8f111b 100644 --- a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/instrument.server.js +++ b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/instrument.server.js @@ -1,9 +1,10 @@ -import { defineCloudflareOptions } from '@sentry/cloudflare'; +import { defineCloudflareOptions, serveTunnelIntegration } from '@sentry/cloudflare'; export default defineCloudflareOptions(env => ({ dsn: env.E2E_TEST_DSN, environment: 'qa', tracesSampleRate: 1.0, tunnel: 'http://localhost:3031/', // proxy server + integrations: import.meta.env.PUBLIC_E2E_TEST_TUNNEL ? [serveTunnelIntegration()] : [], debug: true, })); diff --git a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/package.json b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/package.json index b42e309c021e..d474dc5b8d43 100644 --- a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/package.json @@ -10,7 +10,9 @@ "start": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --port 3030", "test:build": "pnpm install && pnpm build", "test:assert": "TEST_ENV=production playwright test", - "cf-typegen": "wrangler types --include-runtime=false" + "cf-typegen": "wrangler types --include-runtime=false", + "test:build:tunnel": "pnpm install && PUBLIC_E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", + "test:assert:tunnel": "PUBLIC_E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel" }, "dependencies": { "@astrojs/cloudflare": "^13.0.2", @@ -22,7 +24,13 @@ "mysql": "2.18.1", "wrangler": "^4.72.0" }, - "volta": { - "extends": "../../package.json" + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "astro-6-cf-workers (tunnel)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/sentry.client.config.js b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/sentry.client.config.js index 83573d36d0be..c0f094f0dd1a 100644 --- a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/sentry.client.config.js +++ b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/sentry.client.config.js @@ -4,7 +4,7 @@ Sentry.init({ dsn: import.meta.env.PUBLIC_E2E_TEST_DSN, environment: 'qa', tracesSampleRate: 1.0, - tunnel: 'http://localhost:3031/', // proxy server + tunnel: import.meta.env.PUBLIC_E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server integrations: [ Sentry.browserTracingIntegration({ beforeStartSpan: opts => { diff --git a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/tests/tunnel.test.ts new file mode 100644 index 000000000000..ba870eb3e1f9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/tests/tunnel.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, watchForTunnelSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.PUBLIC_E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const APP_NAME = 'astro-6-cf-workers'; +const TUNNEL_PATH = /^\/docs\/[a-z0-9]{1,7}\/$/; + +test('answers the managed tunnel under the base path, without tunnel spans', async ({ page }) => { + const raceTunnelSpan = watchForTunnelSpan( + APP_NAME, + span => + getSpanOp(span) === 'http.server' && + /\/docs\/[a-z0-9]{1,7}\/?$/.test( + String(span.attributes['url.path']?.value ?? span.attributes['url.full']?.value ?? span.name), + ), + ); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto('/docs/'); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect( + await raceTunnelSpan( + span => getSpanOp(span) === 'http.server' && span.is_segment && span.name.includes('test-ssr'), + () => page.request.get('/docs/test-ssr/'), + ), + ).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/astro-6/package.json b/dev-packages/e2e-tests/test-applications/astro-6/package.json index b1cd5e483d9d..d0c24bda389a 100644 --- a/dev-packages/e2e-tests/test-applications/astro-6/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-6/package.json @@ -17,8 +17,5 @@ "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "@sentry/astro": "file:../../packed/sentry-astro-packed.tgz", "astro": "^6.2.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/assert-build.ts index aeb76ee19e32..ad96fabd3f14 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/assert-build.ts @@ -1,97 +1,23 @@ -import * as assert from 'node:assert/strict'; -import * as fs from 'node:fs'; import * as path from 'node:path'; -import { - findInjectedDebugIds, - findSourceMapFiles, - getArtifactBundles, - getAssembleRequests, - getChunkUploadPosts, - getDebugIdPairs, - getSourcemaps, - loadMockServerResults, -} from '@sentry-internal/test-utils'; +import { assertSourceMapUpload } from '@sentry-internal/test-utils'; const isStatic = process.env.E2E_ASTRO_OUTPUT === 'static'; const BUILD_OUTPUT = 'dist'; const OUTPUT_DIRS = isStatic ? [BUILD_OUTPUT] : [path.join(BUILD_OUTPUT, 'client'), path.join(BUILD_OUTPUT, 'server')]; -/** All 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 PRERENDER_MARKER = 'SOURCEMAP_MARKER_PRERENDER'; - -/** Written by the build, see `recordChunksWithoutSourceMap` in `astro.config.mjs`. */ -const debugIdsWithoutSourceMap = new Set( - fs.existsSync('.tmp_debug_ids_without_source_map') - ? fs.readFileSync('.tmp_debug_ids_without_source_map', 'utf8').split('\n').filter(Boolean) - : [], -); - -const requests = loadMockServerResults(); - -console.log(`Captured ${requests.length} requests to mock Sentry server:\n`); -for (const request of requests) { - console.log(` ${request.method} ${request.url} (${request.bodySize} bytes)`); -} -console.log(''); - -assert.ok( - requests.some(r => r.authorization.includes('fake-auth-token')), - 'Expected requests with the configured auth token', -); - -assert.ok( - getChunkUploadPosts(requests).some(r => r.bodySize > 0), - 'Expected at least one chunk upload POST with a non-empty body', -); - -const assembleRequests = getAssembleRequests(requests); -assert.ok(assembleRequests.length > 0, 'Expected at least one assemble request'); -for (const request of assembleRequests) { - assert.ok(request.assembleBody?.projects?.includes('test-project'), 'Expected assemble request for test-project'); - assert.equal(request.assembleBody?.version, 'test-release', 'Expected assemble request to reference the release'); -} - -const bundles = getArtifactBundles(requests); -assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest'); - -const sourcemaps = getSourcemaps(bundles); - -const containsMarker = (marker: string): boolean => - sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))); - -assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded sourcemap carrying the client source'); -if (!isStatic) { - assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded sourcemap carrying the server source'); -} -// Astro 7 writes the prerender build to a hidden `.prerender` folder, which a plain `dist/**/*` glob skips -assert.ok(containsMarker(PRERENDER_MARKER), 'Expected an uploaded sourcemap carrying the prerendered page source'); - -const buildOutput = ['.tmp_build_stdout', '.tmp_build_stderr'] - .filter(file => fs.existsSync(file)) - .map(file => fs.readFileSync(file, 'utf8')) - .join('\n'); -assert.ok( - !buildOutput.includes("Didn't find any matching sources for debug ID upload"), - 'Expected every Vite build to find sources for the debug ID upload', -); - -const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase())); -assert.ok(uploadedDebugIds.size > 0, 'Expected at least one JS/sourcemap pair with matching debug IDs'); - -for (const outputDir of OUTPUT_DIRS) { - const injectedDebugIds = findInjectedDebugIds({ outputDir }); - assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); - - const unuploaded = injectedDebugIds.filter( - debugId => !uploadedDebugIds.has(debugId) && !debugIdsWithoutSourceMap.has(debugId), - ); - assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded sourcemap`); -} - -// The app does not set `build.sourcemap`, so the SDK enables hidden maps and must delete them after upload. -assert.deepEqual(findSourceMapFiles({ outputDir: BUILD_OUTPUT }), [], `Expected no source maps in ${BUILD_OUTPUT}`); +assertSourceMapUpload({ + outputDirs: OUTPUT_DIRS, + // The app does not set `build.sourcemap`, so the SDK enables hidden maps and must delete them after upload. + strippedDirs: [BUILD_OUTPUT], + markers: { + 'client source': 'SOURCEMAP_MARKER_CLIENT', + ...(isStatic ? {} : { 'server source': 'SOURCEMAP_MARKER_SERVER' }), + // Astro 7 writes the prerender build to a hidden `.prerender` folder, which a plain `dist/**/*` glob skips + 'prerendered page source': 'SOURCEMAP_MARKER_PRERENDER', + }, + // Written by the build, see `recordChunksWithoutSourceMap` in `astro.config.mjs`. + debugIdsWithoutSourceMap: '.tmp_debug_ids_without_source_map', +}); console.log('All sourcemap assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/package.json index afe49d64f5c1..586b73f4e4d1 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/package.json @@ -20,9 +20,6 @@ "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "@types/node": "^22.20.0" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "variants": [ { diff --git a/dev-packages/e2e-tests/test-applications/astro-7-static/package.json b/dev-packages/e2e-tests/test-applications/astro-7-static/package.json index 25fbdacf716c..b07b205a94d5 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7-static/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-7-static/package.json @@ -23,9 +23,6 @@ "ioredis": "5.10.1", "mysql": "^2.18.1" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "optional": true } diff --git a/dev-packages/e2e-tests/test-applications/astro-7/astro.config.mjs b/dev-packages/e2e-tests/test-applications/astro-7/astro.config.mjs index 061db869d94b..49370ed1e18b 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7/astro.config.mjs +++ b/dev-packages/e2e-tests/test-applications/astro-7/astro.config.mjs @@ -4,8 +4,13 @@ import { defineConfig } from 'astro/config'; import node from '@astrojs/node'; +// The tunnel variant serves the app under a base path with trailing slashes to cover that the managed tunnel +// follows both. +const tunnelVariant = !!process.env.PUBLIC_E2E_TEST_TUNNEL; + // https://astro.build/config export default defineConfig({ + ...(tunnelVariant && { base: '/docs', trailingSlash: 'always' }), integrations: [ sentry({ debug: true, diff --git a/dev-packages/e2e-tests/test-applications/astro-7/package.json b/dev-packages/e2e-tests/test-applications/astro-7/package.json index b4de76c5e798..5df25e28ff22 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-7/package.json @@ -11,7 +11,9 @@ "test:build": "pnpm install && pnpm build", "test:assert": "pnpm test:prod && pnpm test:dev", "test:prod": "TEST_ENV=production playwright test", - "test:dev": "TEST_ENV=development playwright test db" + "test:dev": "TEST_ENV=development playwright test db", + "test:build:tunnel": "pnpm install && PUBLIC_E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", + "test:assert:tunnel": "PUBLIC_E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 sh -c 'TEST_ENV=production playwright test tunnel && TEST_ENV=development playwright test tunnel'" }, "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", "dependencies": { @@ -23,10 +25,14 @@ "ioredis": "5.10.1", "mysql": "^2.18.1" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { - "optional": true + "optional": true, + "variants": [ + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "astro-7 (tunnel)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/astro-7/sentry.client.config.js b/dev-packages/e2e-tests/test-applications/astro-7/sentry.client.config.js index 83573d36d0be..c0f094f0dd1a 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7/sentry.client.config.js +++ b/dev-packages/e2e-tests/test-applications/astro-7/sentry.client.config.js @@ -4,7 +4,7 @@ Sentry.init({ dsn: import.meta.env.PUBLIC_E2E_TEST_DSN, environment: 'qa', tracesSampleRate: 1.0, - tunnel: 'http://localhost:3031/', // proxy server + tunnel: import.meta.env.PUBLIC_E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server integrations: [ Sentry.browserTracingIntegration({ beforeStartSpan: opts => { diff --git a/dev-packages/e2e-tests/test-applications/astro-7/sentry.server.config.js b/dev-packages/e2e-tests/test-applications/astro-7/sentry.server.config.js index bc90470cef38..88c07f0c1709 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7/sentry.server.config.js +++ b/dev-packages/e2e-tests/test-applications/astro-7/sentry.server.config.js @@ -5,5 +5,6 @@ Sentry.init({ environment: 'qa', tracesSampleRate: 1.0, tunnel: 'http://localhost:3031/', // proxy server + integrations: import.meta.env.PUBLIC_E2E_TEST_TUNNEL ? [Sentry.serveTunnelIntegration()] : [], debug: true, }); diff --git a/dev-packages/e2e-tests/test-applications/astro-7/src/middleware.ts b/dev-packages/e2e-tests/test-applications/astro-7/src/middleware.ts new file mode 100644 index 000000000000..fefaf0abe0b4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7/src/middleware.ts @@ -0,0 +1,14 @@ +import { defineMiddleware } from 'astro:middleware'; + +// The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. +export const onRequest = defineMiddleware((context, next) => { + if ( + import.meta.env.PUBLIC_E2E_TEST_TUNNEL && + context.request.method === 'POST' && + context.request.headers.get('x-auth') !== 'ok' + ) { + return new Response('blocked by user auth', { status: 401 }); + } + + return next(); +}); diff --git a/dev-packages/e2e-tests/test-applications/astro-7/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/astro-7/tests/tunnel.test.ts new file mode 100644 index 000000000000..7fb759f90764 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7/tests/tunnel.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from '@playwright/test'; +import { isServerSpanAt, watchForTunnelSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.PUBLIC_E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const TUNNEL_PATH = /^\/docs\/[a-z0-9]{1,7}\/$/; + +// First in the file, while the server has only served prerendered pages, so no SSR page has run `Sentry.init` yet. +test('answers the managed tunnel when the first page is prerendered', async ({ page }) => { + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto('/docs/test-static/'); + + expect((await tunnelResponsePromise).status()).toBe(200); +}); + +test('answers the managed tunnel under the base path before the app middleware', async ({ page }) => { + // The variant's auth middleware rejects every other POST. + expect((await page.request.post('/docs/anything/', { data: 'x' })).status()).toBe(401); + + const raceTunnelSpan = watchForTunnelSpan('astro-7', isServerSpanAt(/\/docs\/[a-z0-9]{1,7}\/?$/)); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto('/docs/'); + + expect((await tunnelResponsePromise).status()).toBe(200); + expect(await raceTunnelSpan(isServerSpanAt(/^\/docs\/test-ssr/), () => page.request.get('/docs/test-ssr/'))).toBe( + 'anchor', + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-esbuild/assert.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless-esbuild/assert.mjs new file mode 100644 index 000000000000..db243c44e199 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-esbuild/assert.mjs @@ -0,0 +1,6 @@ +import { assertBundlerInstrumentation } from '@sentry-internal/test-utils'; + +// Drives the four built bundles (plain / plugin / plain-external / plugin-external) across the +// build-time and runtime instrumentation paths. The external variants preload the runtime hook +// of `@sentry/aws-serverless` rather than `@sentry/node`, since that is the one a Lambda user has. +assertBundlerInstrumentation('graphql', { importHook: '@sentry/aws-serverless/import' }); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-esbuild/build.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless-esbuild/build.mjs new file mode 100644 index 000000000000..2f9176797576 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-esbuild/build.mjs @@ -0,0 +1,42 @@ +// Same four-way esbuild matrix as the `node-esbuild` app, but with the plugin imported from +// `@sentry/aws-serverless/esbuild`, which is how a Lambda project that bundles with esbuild (CDK, +// SAM, Serverless Framework) would use it. See `node-esbuild/build.mjs` for the variant layout. +import { rmSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; +import { sentryEsbuildPlugin } from '@sentry/aws-serverless/esbuild'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +rmSync(join(__dirname, 'dist'), { recursive: true, force: true }); + +// No auth/release/telemetry — we only care about the build-time transforms and defines. +const makeSentryPlugin = () => + sentryEsbuildPlugin({ + telemetry: false, + sourcemaps: { disable: true }, + release: { create: false, finalize: false, inject: false }, + }); + +function run(name, { external, plugins }) { + return build({ + entryPoints: [join(__dirname, 'src', 'entry.mjs')], + outfile: join(__dirname, 'dist', name, 'main.cjs'), + bundle: true, + platform: 'node', + format: 'cjs', + external: external ? ['graphql'] : [], + minify: false, + logLevel: 'silent', + plugins, + }); +} + +await run('plain', { external: false, plugins: [] }); +await run('plugin', { external: false, plugins: [makeSentryPlugin()] }); +await run('plain-external', { external: true, plugins: [] }); +await run('plugin-external', { external: true, plugins: [makeSentryPlugin()] }); + +// eslint-disable-next-line no-console +console.log('built plain + plugin (inlined) and plain-external + plugin-external with esbuild'); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-esbuild/package.json b/dev-packages/e2e-tests/test-applications/aws-serverless-esbuild/package.json new file mode 100644 index 000000000000..6b3360342abf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-esbuild/package.json @@ -0,0 +1,20 @@ +{ + "name": "aws-serverless-esbuild", + "description": "ensure the esbuild plugin from @sentry/aws-serverless/esbuild build-time instruments a bundled graphql at runtime", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "clean": "npx rimraf node_modules dist pnpm-lock.yaml", + "test:build": "pnpm install && node ./build.mjs", + "test:assert": "node ./assert.mjs" + }, + "dependencies": { + "@sentry/aws-serverless": "file:../../packed/sentry-aws-serverless-packed.tgz" + }, + "devDependencies": { + "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", + "graphql": "16.9.0", + "esbuild": "0.28.2" + } +} diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-esbuild/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/aws-serverless-esbuild/pnpm-workspace.yaml new file mode 100644 index 000000000000..3cdf2ff22992 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-esbuild/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + esbuild: true +minimumReleaseAge: 0 diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-esbuild/src/app.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless-esbuild/src/app.mjs new file mode 100644 index 000000000000..df51731aa1d2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-esbuild/src/app.mjs @@ -0,0 +1,9 @@ +// The workload the bundle instruments: a `graphql` query. Whether `graphql` is inlined into the +// bundle or kept external is decided per-variant by `build.mjs`. +import { buildSchema, graphql } from 'graphql'; + +const schema = buildSchema('type Query { hello: String }'); + +export async function runWorkload() { + return graphql({ schema, source: '{ hello }', rootValue: { hello: () => 'world' } }); +} diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-esbuild/src/entry.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless-esbuild/src/entry.mjs new file mode 100644 index 000000000000..4cfbc3022a7f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-esbuild/src/entry.mjs @@ -0,0 +1,45 @@ +// Bundled entrypoint, run directly with `node` (no `--import` runtime hook). `Sentry.init` runs +// first so the instrumentation's channel subscriber is ready, then the workload is imported and run. +// Spans are collected via the `spanEnd` hook and written to the file named by +// `SENTRY_E2E_RESULT_FILE` for `assert.mjs` to read back. See `node-esbuild/src/entry.mjs`. +import { writeFileSync } from 'node:fs'; +import * as Sentry from '@sentry/aws-serverless'; + +async function main() { + Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + // `@sentry/aws-serverless` leaves the performance integrations out by default, so the one + // that subscribes to the graphql channel has to be added for the spans to show up. + integrations: [Sentry.graphqlIntegration()], + // Isolate the build-time path: with the runtime hook off, the bundler plugin is the only possible + // injector, so a `plain` (no-plugin) build is a true negative. + enableRuntimeChannelInjection: false, + // Hermetic — never hit the network. + transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }), + }); + + const spans = []; + Sentry.getClient()?.on('spanEnd', span => { + const json = Sentry.spanToJSON(span); + spans.push({ name: json.name, origin: json.attributes?.['sentry.origin'] }); + }); + + const { runWorkload } = await import('./app.mjs'); + + let result; + await Sentry.startSpan({ name: 'workload' }, async () => { + result = await runWorkload(); + }); + + await Sentry.flush(2000); + + const resultFile = process.env.SENTRY_E2E_RESULT_FILE; + if (!resultFile) { + throw new Error('SENTRY_E2E_RESULT_FILE is required (assertBundlerInstrumentation sets it).'); + } + writeFileSync(resultFile, JSON.stringify({ result, spans })); + process.exit(0); +} + +void main(); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/package.json b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/package.json index 66acf6b2afeb..eb082877e055 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/package.json +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/package.json @@ -20,10 +20,8 @@ "glob": "^11.0.3", "rimraf": "^5.0.10" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { + "runner": "namespace-profile-sentry-javascript", "variants": [ { "build-command": "NODE_VERSION=22 pnpm test:build", diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/pull-sam-image.sh b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/pull-sam-image.sh index c9659547a4ea..03855b9aaea2 100755 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/pull-sam-image.sh +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/pull-sam-image.sh @@ -1,7 +1,7 @@ #!/bin/bash # Pull the Lambda Node docker image for SAM local. NODE_VERSION should be the major only (e.g. 20). -# Defaults to 20 to match the repo's Volta Node major (see root package.json "volta.node"). +# Defaults to 20, the oldest node major the SDK supports (see `engines` in packages/aws-serverless/package.json). set -e diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/lambda-fixtures.ts b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/lambda-fixtures.ts index 0d4d123f05c3..1a041f9cd638 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/lambda-fixtures.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/lambda-fixtures.ts @@ -14,7 +14,7 @@ const SAM_START_TIMEOUT_MS = 90_000; const SAM_START_ATTEMPTS = 2; const SAM_OUTPUT_MAX_CHARS = 20_000; -/** Major Node for SAM `--invoke-image`; default matches root `package.json` `volta.node` and `pull-sam-image.sh`. */ +/** Major Node for SAM `--invoke-image`; default is the oldest major the SDK supports and matches `pull-sam-image.sh`. */ const DEFAULT_NODE_VERSION_MAJOR = '20'; const SAM_INSTALL_ERROR = diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts index 538b533967e0..f3ae0d58ceb0 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts @@ -37,7 +37,7 @@ test.describe('Lambda layer', () => { data: { 'sentry.sample_rate': 1, 'sentry.segment.name.source': 'component', - 'sentry.origin': 'auto.aws_lambda', + 'sentry.origin': 'auto.function.aws_serverless', 'sentry.op': 'function.aws', 'cloud.account.id': '012345678912', 'cloud.platform': 'aws_lambda', @@ -49,7 +49,7 @@ test.describe('Lambda layer', () => { 'sentry.kind': 'server', }, op: 'function.aws', - origin: 'auto.aws_lambda', + origin: 'auto.function.aws_serverless', span_id: expect.stringMatching(/[a-f0-9]{16}/), status: 'ok', trace_id: expect.stringMatching(/[a-f0-9]{32}/), @@ -108,7 +108,7 @@ test.describe('Lambda layer', () => { data: { 'sentry.sample_rate': 1, 'sentry.segment.name.source': 'component', - 'sentry.origin': 'auto.aws_lambda', + 'sentry.origin': 'auto.function.aws_serverless', 'sentry.op': 'function.aws', 'cloud.account.id': '012345678912', 'cloud.platform': 'aws_lambda', @@ -120,7 +120,7 @@ test.describe('Lambda layer', () => { 'sentry.kind': 'server', }, op: 'function.aws', - origin: 'auto.aws_lambda', + origin: 'auto.function.aws_serverless', span_id: expect.stringMatching(/[a-f0-9]{16}/), status: 'ok', trace_id: expect.stringMatching(/[a-f0-9]{32}/), @@ -234,7 +234,7 @@ test.describe('Lambda layer', () => { data: { 'sentry.sample_rate': 1, 'sentry.segment.name.source': 'component', - 'sentry.origin': 'auto.aws_lambda', + 'sentry.origin': 'auto.function.aws_serverless', 'sentry.op': 'function.aws', 'cloud.account.id': '012345678912', 'cloud.platform': 'aws_lambda', @@ -246,7 +246,7 @@ test.describe('Lambda layer', () => { 'sentry.kind': 'server', }, op: 'function.aws', - origin: 'auto.aws_lambda', + origin: 'auto.function.aws_serverless', span_id: expect.stringMatching(/[a-f0-9]{16}/), status: 'ok', trace_id: expect.stringMatching(/[a-f0-9]{32}/), @@ -283,11 +283,11 @@ test.describe('Lambda layer', () => { expect(transactionEvent.contexts?.trace).toEqual( expect.objectContaining({ op: 'function.aws', - origin: 'auto.aws_lambda', + origin: 'auto.function.aws_serverless', status: 'ok', data: expect.objectContaining({ 'sentry.op': 'function.aws', - 'sentry.origin': 'auto.aws_lambda', + 'sentry.origin': 'auto.function.aws_serverless', 'sentry.kind': 'server', 'cloud.resource_id': 'arn:aws:lambda:us-east-1:012345678912:function:LayerCallback', 'faas.name': 'LayerCallback', @@ -337,7 +337,8 @@ test.describe('Lambda layer', () => { const transactionEvent = await transactionEventPromise; expect(transactionEvent.contexts?.trace?.status).toBe('ok'); - expect(transactionEvent.timestamp! - transactionEvent.start_timestamp!).toBeGreaterThanOrEqual(0.2); + // Node timers can fire up to a millisecond early, so the 200 ms callback delay can measure as 199.x ms + expect(transactionEvent.timestamp! - transactionEvent.start_timestamp!).toBeGreaterThanOrEqual(0.19); }); test('sets error status when a callback-style handler calls back with an error after it returned', async ({ @@ -360,7 +361,7 @@ test.describe('Lambda layer', () => { const [errorEvent, transactionEvent] = await Promise.all([errorEventPromise, transactionEventPromise]); expect(transactionEvent.contexts?.trace?.status).toBe('internal_error'); - expect(transactionEvent.timestamp! - transactionEvent.start_timestamp!).toBeGreaterThanOrEqual(0.2); + expect(transactionEvent.timestamp! - transactionEvent.start_timestamp!).toBeGreaterThanOrEqual(0.19); expect(errorEvent.contexts?.trace?.trace_id).toBe(transactionEvent.contexts?.trace?.trace_id); }); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/package.json b/dev-packages/e2e-tests/test-applications/aws-serverless/package.json index 1088ea1b3120..e8ef0fc4331a 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/package.json +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/package.json @@ -18,8 +18,5 @@ "aws-cdk-lib": "^2.210.0", "constructs": "^10.4.2", "rimraf": "^5.0.10" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/pull-sam-image.sh b/dev-packages/e2e-tests/test-applications/aws-serverless/pull-sam-image.sh index c9659547a4ea..03855b9aaea2 100755 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/pull-sam-image.sh +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/pull-sam-image.sh @@ -1,7 +1,7 @@ #!/bin/bash # Pull the Lambda Node docker image for SAM local. NODE_VERSION should be the major only (e.g. 20). -# Defaults to 20 to match the repo's Volta Node major (see root package.json "volta.node"). +# Defaults to 20, the oldest node major the SDK supports (see `engines` in packages/aws-serverless/package.json). set -e diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts index 0d4d123f05c3..1a041f9cd638 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts @@ -14,7 +14,7 @@ const SAM_START_TIMEOUT_MS = 90_000; const SAM_START_ATTEMPTS = 2; const SAM_OUTPUT_MAX_CHARS = 20_000; -/** Major Node for SAM `--invoke-image`; default matches root `package.json` `volta.node` and `pull-sam-image.sh`. */ +/** Major Node for SAM `--invoke-image`; default is the oldest major the SDK supports and matches `pull-sam-image.sh`. */ const DEFAULT_NODE_VERSION_MAJOR = '20'; const SAM_INSTALL_ERROR = diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts index e9f687cdf2be..35a262a25e64 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts @@ -16,7 +16,7 @@ function assertLambdaTrace(spans: SerializedStreamedSpan[], functionName: string expect(segmentSpan?.attributes).toMatchObject({ 'sentry.op': { value: 'function.aws', type: 'string' }, - 'sentry.origin': { value: 'auto.aws_lambda', type: 'string' }, + 'sentry.origin': { value: 'auto.function.aws_serverless', type: 'string' }, 'sentry.kind': { value: 'server', type: 'string' }, 'sentry.segment.name.source': { value: 'component', type: 'string' }, 'cloud.account.id': { value: '012345678912', type: 'string' }, diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/package.json b/dev-packages/e2e-tests/test-applications/browser-bfcache/package.json index 1db45bd6f42e..6ea89590f8c5 100644 --- a/dev-packages/e2e-tests/test-applications/browser-bfcache/package.json +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/package.json @@ -21,7 +21,7 @@ "typescript": "~5.8.3", "vite": "^7.3.2" }, - "volta": { - "extends": "../../package.json" + "sentryTest": { + "runner": "namespace-profile-sentry-javascript" } } diff --git a/dev-packages/e2e-tests/test-applications/browser-mfe-vite/package.json b/dev-packages/e2e-tests/test-applications/browser-mfe-vite/package.json index a1325c48dd0b..33ef345fe60e 100644 --- a/dev-packages/e2e-tests/test-applications/browser-mfe-vite/package.json +++ b/dev-packages/e2e-tests/test-applications/browser-mfe-vite/package.json @@ -16,8 +16,5 @@ "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "concurrently": "^8.2.2" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json index fdaf1b75ac9e..ef67e46ed634 100644 --- a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json @@ -20,8 +20,5 @@ "dependencies": { "@sentry/browser": "file:../../packed/sentry-browser-packed.tgz", "@sentry/vite-plugin": "^5.4.1" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/bun-bytecode/package.json b/dev-packages/e2e-tests/test-applications/bun-bytecode/package.json index 8b4f6d7a3668..e777afdfa887 100644 --- a/dev-packages/e2e-tests/test-applications/bun-bytecode/package.json +++ b/dev-packages/e2e-tests/test-applications/bun-bytecode/package.json @@ -12,8 +12,5 @@ }, "devDependencies": { "bun-types": "^1.2.9" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/bun-express/package.json b/dev-packages/e2e-tests/test-applications/bun-express/package.json index 3e7f7ec66245..d657af4c94f6 100644 --- a/dev-packages/e2e-tests/test-applications/bun-express/package.json +++ b/dev-packages/e2e-tests/test-applications/bun-express/package.json @@ -19,8 +19,5 @@ "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "@types/express": "^5.0.0", "bun-types": "^1.2.9" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/bun-mysql/package.json b/dev-packages/e2e-tests/test-applications/bun-mysql/package.json index 535242ef7fa7..5527205df01a 100644 --- a/dev-packages/e2e-tests/test-applications/bun-mysql/package.json +++ b/dev-packages/e2e-tests/test-applications/bun-mysql/package.json @@ -18,8 +18,5 @@ "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "bun-types": "^1.2.9" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-agent/package.json index 703b181b4618..45e8c7dcda56 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-agent/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/package.json @@ -43,8 +43,5 @@ }, "sentryTest": { "optional": true - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/package.json index 27a72cada9fc..ccdb6208ee1f 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/package.json @@ -30,8 +30,5 @@ "vite": "^8.1.5", "wrangler": "^4.114.0", "ws": "^8.21.1" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/package.json index 4e2b9827b56e..fedc2c62f3a2 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/package.json @@ -32,9 +32,6 @@ "vite": "7.3.5", "wrangler": "^4.114.0" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "optional": true } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/package.json index e446c93773df..48963fd70fa8 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/package.json @@ -23,8 +23,5 @@ "typescript": "^5.5.2", "wrangler": "^4.61.0", "ws": "^8.18.3" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mastra/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-mastra/package.json index 85639e8bd92f..15ba4efc715c 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-mastra/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mastra/package.json @@ -33,9 +33,6 @@ "wrangler": "^4.86.0", "ws": "^8.18.3" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "optional": true } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mastra/tests/mastra.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mastra/tests/mastra.test.ts index d07faa5b2931..a7a291a596c3 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-mastra/tests/mastra.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mastra/tests/mastra.test.ts @@ -54,7 +54,7 @@ test('captures Mastra agent spans (invoke_agent, chat, execute_tool) with inputs ).toEqual([]); expect( traceSpans - .filter(span => String(attrValue(span, 'sentry.origin') ?? '').startsWith('auto.vercelai')) + .filter(span => String(attrValue(span, 'sentry.origin') ?? '').startsWith('auto.ai.vercelai')) .map(describeSpan), ).toEqual([]); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/package.json index 6d647ef1a57b..0549f5cae5b7 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/package.json @@ -14,9 +14,10 @@ "test:dev": "TEST_ENV=development playwright test" }, "dependencies": { - "@modelcontextprotocol/sdk": "1.29.0", + "@modelcontextprotocol/sdk": "1.32.1", + "@modelcontextprotocol/server": "2.3.1", "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", - "agents": "0.11.9", + "agents": "0.31.0", "zod": "^4.3.6" }, "devDependencies": { @@ -25,8 +26,5 @@ "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "typescript": "^6.0.3", "wrangler": "^4.86.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json index 3643b8d70e28..6b2cb1ea3043 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json @@ -26,8 +26,5 @@ "typescript": "^5.5.2", "wrangler": "^4.61.0", "ws": "^8.18.3" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/package.json index a1e072102ab5..b7de7ca0eea5 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/package.json @@ -26,8 +26,5 @@ "vite": "7.3.2", "wrangler": "^4.61.0", "ws": "^8.18.3" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/package.json index b182a4f209d8..4d537a600017 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/package.json @@ -22,14 +22,11 @@ "@cloudflare/workers-types": "^4.20260426.0", "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", - "@sentry/conventions": "0.27.0", + "@sentry/conventions": "0.29.0", "typescript": "^5.5.2", "vite": "7.3.5", "wrangler": "^4.136.2" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "optional": true, "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/tests/pi-durable.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/tests/pi-durable.test.ts index bf2a4092188d..0c8f04674b12 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/tests/pi-durable.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/tests/pi-durable.test.ts @@ -17,6 +17,7 @@ import { collectStreamedSpans, getSpanOp, waitForError } from '@sentry-internal/ import { newAgentId, prompt, submitPrompt, waitForOperation } from './utils'; const APP = 'cloudflare-pi-durable'; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; test('traces a PiHarness prompt as invoke_agent with chat, execute_tool and provider spans', async ({ baseURL }) => { // A run is its own trace, and its `invoke_agent` segment ends last. Only this test calls get_weather. @@ -43,8 +44,8 @@ test('traces a PiHarness prompt as invoke_agent with chat, execute_tool and prov const providerCalls = spans.filter(span => getSpanOp(span) === HTTP_CLIENT); expect(agent.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.pi_durable'); - // The PiHarness root session is pi conversation 1, in a Harness of its own per Durable Object. - expect(agent.attributes[GEN_AI_CONVERSATION_ID]?.value).toMatch(/^[0-9a-f]{32}:1$/); + // The provider session id pi-durable keeps with the conversation. + expect(agent.attributes[GEN_AI_CONVERSATION_ID]?.value).toMatch(UUID); // The request that submitted the prompt is a trace of its own: the scheduler runs the work. expect(spans.some(span => getSpanOp(span) === HTTP_SERVER)).toBe(false); // pi-durable keeps its state in the `pi_` tables of `PiHarness`, whose statements start no span. @@ -141,6 +142,9 @@ test('resumes a run in a new trace after the Durable Object resets during a tool expect(getSpanOp(agent)).toBe(GEN_AI_INVOKE_AGENT); expect(tool.parent_span_id).toBe(agent.span_id); expect(finalAnswer?.parent_span_id).toBe(agent.span_id); + // The restarted object reads the conversation id from storage, also when the run resumes with a tool call. + expect(agent.attributes[GEN_AI_CONVERSATION_ID]?.value).toMatch(UUID); + expect(tool.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(agent.attributes[GEN_AI_CONVERSATION_ID]?.value); // The request that called the tool ran before the reset, so this trace starts with the rerun of the // tool. A run that never reset would start with that request. expect(chats.every(chat => chat.start_timestamp >= tool.start_timestamp)).toBe(true); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-think/package.json index 61af0e0803a1..584491363792 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-think/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/package.json @@ -33,9 +33,6 @@ "wrangler": "^4.136.2", "ws": "^8.18.3" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "optional": true, "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/errors.test.ts index 76f65c74e01b..d373d9e00cb0 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/errors.test.ts @@ -33,7 +33,7 @@ test('captures an error thrown inside a Think tool and marks its span errored', const exception = error.exception?.values?.[0]; expect(exception?.type).toBe('Error'); expect(exception?.value).toBe('Think tool failed on purpose'); - expect(exception?.mechanism?.type).toBe('auto.vercelai.channel'); + expect(exception?.mechanism?.type).toBe('auto.ai.vercelai'); const toolSpan = spans.find(span => span.attributes?.['gen_ai.tool.name']?.value === 'fail_now'); expect(getSpanOp(toolSpan!)).toBe('gen_ai.execute_tool'); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/think.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/think.test.ts index 1368b59298a7..6190178bb72a 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/think.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/think.test.ts @@ -36,7 +36,7 @@ test('captures the invoke_agent / generate_content / execute_tool hierarchy for const generateContent = spans.find(span => getSpanOp(span) === 'gen_ai.generate_content'); const executeTool = spans.find(span => getSpanOp(span) === 'gen_ai.execute_tool'); - expect(attr(invokeAgent, 'sentry.origin')).toBe('auto.vercelai.channel'); + expect(attr(invokeAgent, 'sentry.origin')).toBe('auto.ai.vercelai'); expect(attr(invokeAgent, 'gen_ai.operation.name')).toBe('invoke_agent'); // Think passes `this.constructor.name` to the AI SDK as the `functionId`, so this only holds while // the build-time wrapper leaves the user's class name alone (#24700). diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/package.json index e14df947054b..8f75bd48b26b 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/package.json @@ -25,8 +25,5 @@ "vite": "7.3.5", "wrangler": "^4.86.0", "ws": "^8.18.3" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/tests/index.test.ts index 1645f34851a7..3b72d063622b 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/tests/index.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/tests/index.test.ts @@ -30,7 +30,7 @@ test('captures Vercel AI v7 spans with nodejs_compat using tracing channels', as 'gen_ai.usage.output_tokens': { value: 20, type: 'integer' }, 'gen_ai.usage.total_tokens': { value: 30, type: 'integer' }, 'sentry.op': { value: 'gen_ai.invoke_agent', type: 'string' }, - 'sentry.origin': { value: 'auto.vercelai.channel', type: 'string' }, + 'sentry.origin': { value: 'auto.ai.vercelai', type: 'string' }, }), }); @@ -49,7 +49,7 @@ test('captures Vercel AI v7 spans with nodejs_compat using tracing channels', as 'gen_ai.usage.output_tokens': { value: 20, type: 'integer' }, 'gen_ai.usage.total_tokens': { value: 30, type: 'integer' }, 'sentry.op': { value: 'gen_ai.generate_content', type: 'string' }, - 'sentry.origin': { value: 'auto.vercelai.channel', type: 'string' }, + 'sentry.origin': { value: 'auto.ai.vercelai', type: 'string' }, }), }); }); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json index be9b44cc4422..f32066af21d8 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json @@ -19,15 +19,13 @@ "@cloudflare/workers-types": "^5.20260727.1", "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", + "@sentry/conventions": "0.29.0", "@types/node": "^26.1.2", "sentry": "~0.44.1", "typescript": "~6.0.3", "vite": "^8.1.5", "wrangler": "^4.114.0" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "optional": true } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts index 990859d99f7e..641a211dd92a 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts @@ -46,6 +46,17 @@ export default { }); return Response.json({ traceId: spanContext?.traceId }); } + case '/test-workers-ai-clairic': { + await env.AI.run('@cf/cloudflare/clairic', { + model: 'clairic', + state: 'Invoice INV-2291 from Northwind Supply. Total due: $1,080.00.', + fields: { + invoice_number: { type: 'text' }, + total: { type: 'number', decimals: 2, unit: 'USD', instructions: 'Amount due' }, + }, + }); + return Response.json({ traceId: spanContext?.traceId }); + } case '/test-span': return Response.json({ spanId: spanContext?.spanId, traceId: spanContext?.traceId }); case '/test-workflow-sleep': { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts index fd79fa5b7743..ea4e136d5536 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts @@ -1,5 +1,13 @@ import { randomBytes } from 'node:crypto'; import { expect, test } from '@playwright/test'; +import { + GEN_AI_INPUT_MESSAGES, + GEN_AI_OPERATION_NAME, + GEN_AI_OUTPUT_MESSAGES, + GEN_AI_REQUEST_MODEL, + GEN_AI_RESPONSE_MODEL, +} from '@sentry/conventions/attributes'; +import { GEN_AI_CHAT, GEN_AI_DECIDE, HTTP_SERVER } from '@sentry/conventions/op'; import { EVENT_POLLING_OPTIONS, fetchSpanAttributes, @@ -37,7 +45,7 @@ test('Sends an unhandled exception and its request span to Sentry', async () => console.log(`Polling for unhandled error: sentry trace view ${traceTarget(traceId)}`); await expect.poll(() => findErrorInTrace(traceId), EVENT_POLLING_OPTIONS).toBeDefined(); - await expect.poll(() => findSpanInTrace(traceId, 'http.server'), EVENT_POLLING_OPTIONS).toBeDefined(); + await expect.poll(() => findSpanInTrace(traceId, HTTP_SERVER), EVENT_POLLING_OPTIONS).toBeDefined(); }); test('Sends a request span to Sentry', async () => { @@ -48,7 +56,7 @@ test('Sends a request span to Sentry', async () => { console.log(`Polling for request spanId ${spanId}: sentry trace view ${traceTarget(traceId)}`); await expect - .poll(() => findSpanInTrace(traceId, 'http.server'), EVENT_POLLING_OPTIONS) + .poll(() => findSpanInTrace(traceId, HTTP_SERVER), EVENT_POLLING_OPTIONS) .toMatchObject({ event_id: spanId }); }); @@ -82,15 +90,15 @@ test('Sends a Workers AI gen_ai span to Sentry', async () => { let spanId: string | undefined; await expect - .poll(() => (spanId = findSpanInTrace(traceId, 'gen_ai.chat')?.event_id), EVENT_POLLING_OPTIONS) + .poll(() => (spanId = findSpanInTrace(traceId, GEN_AI_CHAT)?.event_id), EVENT_POLLING_OPTIONS) .toBeDefined(); // Sentry stores `gen_ai.response.text` as `gen_ai.output.messages`. await expect .poll(() => fetchSpanAttributes(traceId, spanId!), EVENT_POLLING_OPTIONS) .toMatchObject({ - 'gen_ai.input.messages': expect.stringContaining('Say hi'), - 'gen_ai.output.messages': expect.stringContaining('"role":"assistant"'), + [GEN_AI_INPUT_MESSAGES]: expect.stringContaining('Say hi'), + [GEN_AI_OUTPUT_MESSAGES]: expect.stringContaining('"role":"assistant"'), }); }); @@ -101,17 +109,17 @@ test('Sends a Workers AI gen_ai.decide span for a TypeSafe Jev call to Sentry', let spanId: string | undefined; await expect - .poll(() => (spanId = findSpanInTrace(traceId, 'gen_ai.decide')?.event_id), EVENT_POLLING_OPTIONS) + .poll(() => (spanId = findSpanInTrace(traceId, GEN_AI_DECIDE)?.event_id), EVENT_POLLING_OPTIONS) .toBeDefined(); await expect .poll(() => fetchSpanAttributes(traceId, spanId!), EVENT_POLLING_OPTIONS) .toMatchObject({ - 'gen_ai.operation.name': 'decide', - 'gen_ai.request.model': 'typesafe/jev', - 'gen_ai.response.model': expect.stringMatching(/^jev-/), - 'gen_ai.input.messages': expect.stringContaining('My payouts have been failing'), - 'gen_ai.output.messages': expect.stringContaining('"is_urgent"'), + [GEN_AI_OPERATION_NAME]: 'decide', + [GEN_AI_REQUEST_MODEL]: 'typesafe/jev', + [GEN_AI_RESPONSE_MODEL]: expect.stringMatching(/^jev-/), + [GEN_AI_INPUT_MESSAGES]: expect.stringContaining('My payouts have been failing'), + [GEN_AI_OUTPUT_MESSAGES]: expect.stringContaining('"is_urgent"'), }); }); @@ -122,16 +130,40 @@ test('Sends a Workers AI gen_ai.decide span for a Clef call to Sentry', async () let spanId: string | undefined; await expect - .poll(() => (spanId = findSpanInTrace(traceId, 'gen_ai.decide')?.event_id), EVENT_POLLING_OPTIONS) + .poll(() => (spanId = findSpanInTrace(traceId, GEN_AI_DECIDE)?.event_id), EVENT_POLLING_OPTIONS) + .toBeDefined(); + + await expect + .poll(() => fetchSpanAttributes(traceId, spanId!), EVENT_POLLING_OPTIONS) + .toMatchObject({ + [GEN_AI_OPERATION_NAME]: 'decide', + [GEN_AI_REQUEST_MODEL]: '@cf/cloudflare/clef', + [GEN_AI_RESPONSE_MODEL]: expect.stringContaining('clef'), + [GEN_AI_INPUT_MESSAGES]: expect.stringContaining('Checkout has been failing'), + [GEN_AI_OUTPUT_MESSAGES]: expect.stringContaining('"urgent"'), + }); +}); + +test('Sends a Workers AI gen_ai.extract span for a Clairic call to Sentry', async () => { + const { traceId }: { traceId: string } = JSON.parse( + await fetchFromWorker(`${workerUrl}/test-workers-ai-clairic`, 200), + ); + + console.log(`Polling for gen_ai.extract span: sentry trace view ${traceTarget(traceId)}`); + + let spanId: string | undefined; + // TODO: Use `GEN_AI_EXTRACT` from `@sentry/conventions/op` once getsentry/sentry-conventions#728 is released + await expect + .poll(() => (spanId = findSpanInTrace(traceId, 'gen_ai.extract')?.event_id), EVENT_POLLING_OPTIONS) .toBeDefined(); await expect .poll(() => fetchSpanAttributes(traceId, spanId!), EVENT_POLLING_OPTIONS) .toMatchObject({ - 'gen_ai.operation.name': 'decide', - 'gen_ai.request.model': '@cf/cloudflare/clef', - 'gen_ai.response.model': expect.stringContaining('clef'), - 'gen_ai.input.messages': expect.stringContaining('Checkout has been failing'), - 'gen_ai.output.messages': expect.stringContaining('"urgent"'), + [GEN_AI_OPERATION_NAME]: 'extract', + [GEN_AI_REQUEST_MODEL]: '@cf/cloudflare/clairic', + [GEN_AI_RESPONSE_MODEL]: expect.stringContaining('clairic'), + [GEN_AI_INPUT_MESSAGES]: expect.stringContaining('Northwind Supply'), + [GEN_AI_OUTPUT_MESSAGES]: expect.stringContaining('INV-2291'), }); }); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-static/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-static/package.json index f17fcc0e071b..827a00fbf357 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-static/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-static/package.json @@ -23,8 +23,5 @@ "typescript": "^5.5.2", "wrangler": "^4.61.0", "ws": "^8.18.3" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-workflow-legacy/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-workflow-legacy/package.json index 96b3dbdd0126..ed9236a979ce 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-workflow-legacy/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-workflow-legacy/package.json @@ -19,8 +19,5 @@ "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "typescript": "^5.5.2", "wrangler": "4.70.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers/package.json index 9b331ea42a6f..4701525fca36 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/package.json @@ -23,8 +23,5 @@ "typescript": "^5.5.2", "wrangler": "^4.61.0", "ws": "^8.18.3" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/package.json index 276cf23367f4..695f54736d50 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/package.json @@ -23,8 +23,5 @@ "typescript": "^5.5.2", "wrangler": "^4.61.0", "ws": "^8.18.3" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/create-react-app/package.json b/dev-packages/e2e-tests/test-applications/create-react-app/package.json index 2c95f69f6ae3..9858e5c45875 100644 --- a/dev-packages/e2e-tests/test-applications/create-react-app/package.json +++ b/dev-packages/e2e-tests/test-applications/create-react-app/package.json @@ -39,8 +39,5 @@ "last 1 firefox version", "last 1 safari version" ] - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/package.json b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/package.json index 7a65706203a0..3d9a8c55afca 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/package.json +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/package.json @@ -45,8 +45,5 @@ "typescript": "^5.1.6", "vite": "^5.4.11", "vite-tsconfig-paths": "^4.2.1" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/package.json b/dev-packages/e2e-tests/test-applications/create-remix-app-express/package.json index fe13510bd771..f15a38048bef 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/package.json +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/package.json @@ -49,8 +49,5 @@ "typescript": "^5.1.6", "vite": "^5.4.11", "vite-tsconfig-paths": "^4.2.1" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/package.json b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/package.json index 70ceed4e0bed..9023c5b8487a 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/package.json +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/package.json @@ -29,8 +29,5 @@ "@types/react-dom": "^18.0.11", "eslint": "^8.38.0", "typescript": "^5.0.4" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-static/package.json b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-static/package.json index d8d6344d1712..efa2c4cf4761 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-static/package.json +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-static/package.json @@ -36,8 +36,5 @@ "typescript": "^5.1.6", "vite": "^5.4.11", "vite-tsconfig-paths": "^4.2.1" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/package.json b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/package.json index d8d6344d1712..efa2c4cf4761 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/package.json +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/package.json @@ -36,8 +36,5 @@ "typescript": "^5.1.6", "vite": "^5.4.11", "vite-tsconfig-paths": "^4.2.1" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/package.json index 8a24a5a34fbc..1020125e3214 100644 --- a/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/package.json @@ -17,9 +17,6 @@ "vitest": "^4.1.11", "@sentry/rollup-plugin": "^5.4.1" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "optional": true } diff --git a/dev-packages/e2e-tests/test-applications/default-browser-static/package.json b/dev-packages/e2e-tests/test-applications/default-browser-static/package.json index cdd05d10d17b..003d3181f2e4 100644 --- a/dev-packages/e2e-tests/test-applications/default-browser-static/package.json +++ b/dev-packages/e2e-tests/test-applications/default-browser-static/package.json @@ -34,8 +34,5 @@ "serve": "14.0.1", "terser-webpack-plugin": "^5.3.10", "html-webpack-plugin": "^5.6.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/default-browser/package.json b/dev-packages/e2e-tests/test-applications/default-browser/package.json index f77d34c59e5f..518c764d0333 100644 --- a/dev-packages/e2e-tests/test-applications/default-browser/package.json +++ b/dev-packages/e2e-tests/test-applications/default-browser/package.json @@ -34,8 +34,5 @@ "serve": "14.0.1", "terser-webpack-plugin": "^5.3.10", "html-webpack-plugin": "^5.6.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/deno/package.json b/dev-packages/e2e-tests/test-applications/deno/package.json index 97196e06b30d..df4fb4e3e705 100644 --- a/dev-packages/e2e-tests/test-applications/deno/package.json +++ b/dev-packages/e2e-tests/test-applications/deno/package.json @@ -25,9 +25,6 @@ "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "variants": [ { diff --git a/dev-packages/e2e-tests/test-applications/effect-3-browser/package.json b/dev-packages/e2e-tests/test-applications/effect-3-browser/package.json index a760831a1b40..d46cae9e4194 100644 --- a/dev-packages/e2e-tests/test-applications/effect-3-browser/package.json +++ b/dev-packages/e2e-tests/test-applications/effect-3-browser/package.json @@ -35,8 +35,5 @@ "last 1 firefox version", "last 1 safari version" ] - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/effect-3-node/package.json b/dev-packages/e2e-tests/test-applications/effect-3-node/package.json index 48e05c81d61c..e30af75e8107 100644 --- a/dev-packages/e2e-tests/test-applications/effect-3-node/package.json +++ b/dev-packages/e2e-tests/test-applications/effect-3-node/package.json @@ -22,8 +22,5 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/effect-4-browser/package.json b/dev-packages/e2e-tests/test-applications/effect-4-browser/package.json index 611eaeb77fc2..bf1091351fb7 100644 --- a/dev-packages/e2e-tests/test-applications/effect-4-browser/package.json +++ b/dev-packages/e2e-tests/test-applications/effect-4-browser/package.json @@ -35,8 +35,5 @@ "last 1 firefox version", "last 1 safari version" ] - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/effect-4-node/package.json b/dev-packages/e2e-tests/test-applications/effect-4-node/package.json index d76dd79967d3..3c0374f1bd07 100644 --- a/dev-packages/e2e-tests/test-applications/effect-4-node/package.json +++ b/dev-packages/e2e-tests/test-applications/effect-4-node/package.json @@ -21,8 +21,5 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun-static/package.json b/dev-packages/e2e-tests/test-applications/elysia-bun-static/package.json index 330d4d42bfeb..7aba5a42edd0 100644 --- a/dev-packages/e2e-tests/test-applications/elysia-bun-static/package.json +++ b/dev-packages/e2e-tests/test-applications/elysia-bun-static/package.json @@ -18,8 +18,5 @@ "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "bun-types": "^1.2.9" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/package.json b/dev-packages/e2e-tests/test-applications/elysia-bun/package.json index 454307a5601b..06118a75e1c1 100644 --- a/dev-packages/e2e-tests/test-applications/elysia-bun/package.json +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/package.json @@ -18,8 +18,5 @@ "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "bun-types": "^1.2.9" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/package.json b/dev-packages/e2e-tests/test-applications/elysia-node/package.json index 50aa77e227ae..f5b18d32baed 100644 --- a/dev-packages/e2e-tests/test-applications/elysia-node/package.json +++ b/dev-packages/e2e-tests/test-applications/elysia-node/package.json @@ -18,8 +18,5 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/ember-classic/package.json b/dev-packages/e2e-tests/test-applications/ember-classic/package.json index c5b9e4ea812e..8da9cf54b799 100644 --- a/dev-packages/e2e-tests/test-applications/ember-classic/package.json +++ b/dev-packages/e2e-tests/test-applications/ember-classic/package.json @@ -76,8 +76,5 @@ }, "ember": { "edition": "octane" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/ember-embroider/package.json b/dev-packages/e2e-tests/test-applications/ember-embroider/package.json index dc2a3fc6832c..a39ef34b9fd6 100644 --- a/dev-packages/e2e-tests/test-applications/ember-embroider/package.json +++ b/dev-packages/e2e-tests/test-applications/ember-embroider/package.json @@ -65,8 +65,5 @@ }, "ember": { "edition": "octane" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/ember-strict-resolver/package.json b/dev-packages/e2e-tests/test-applications/ember-strict-resolver/package.json index 7d235b5e9dc0..1ec62aa127ef 100644 --- a/dev-packages/e2e-tests/test-applications/ember-strict-resolver/package.json +++ b/dev-packages/e2e-tests/test-applications/ember-strict-resolver/package.json @@ -42,9 +42,6 @@ "ember": { "edition": "octane" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "variants": [ { diff --git a/dev-packages/e2e-tests/test-applications/ember-vite/package.json b/dev-packages/e2e-tests/test-applications/ember-vite/package.json index 9870657e5024..be30b4da1403 100644 --- a/dev-packages/e2e-tests/test-applications/ember-vite/package.json +++ b/dev-packages/e2e-tests/test-applications/ember-vite/package.json @@ -66,8 +66,5 @@ }, "ember": { "edition": "octane" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/package.json b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/package.json index 2dbf1013a228..35f5df09e3e3 100644 --- a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/package.json +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/package.json @@ -48,8 +48,5 @@ "label": "gen-ai-libraries (cloudflare)" } ] - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/generic-ts5.0/package.json b/dev-packages/e2e-tests/test-applications/generic-ts5.0/package.json index fb793e905d83..d3164ac7d6ca 100644 --- a/dev-packages/e2e-tests/test-applications/generic-ts5.0/package.json +++ b/dev-packages/e2e-tests/test-applications/generic-ts5.0/package.json @@ -19,8 +19,5 @@ "@sentry/node": "file:../../packed/sentry-node-packed.tgz", "@sentry/replay": "file:../../packed/sentry-replay-packed.tgz", "@sentry/wasm": "file:../../packed/sentry-wasm-packed.tgz" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json b/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json index 1864b16c27f4..53a6fc1b33b6 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json @@ -9,7 +9,8 @@ "dev:bun": "bun src/entry.bun.ts", "dev:deno": "deno run --allow-net --allow-env --allow-read src/entry.deno.ts", "build": "wrangler deploy --dry-run", - "test:build": "pnpm install && pnpm build", + "test:build": "pnpm install && pnpm typecheck && pnpm build", + "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", "test:assert": "TEST_ENV=production playwright test" }, "dependencies": { @@ -29,9 +30,6 @@ "typescript": "^5.5.2", "wrangler": "^4.61.0" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "variants": [ { diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/sentry.typecheck.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/sentry.typecheck.ts new file mode 100644 index 000000000000..9df86b4b4b85 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/sentry.typecheck.ts @@ -0,0 +1,19 @@ +// Only `tsc` in `test:build` reads this file. Nothing runs it. +import { sentry as sentryBun } from '@sentry/hono/bun'; +import { sentry as sentryCloudflare } from '@sentry/hono/cloudflare'; +import { sentry as sentryDeno } from '@sentry/hono/deno'; +import { sentry as sentryNode } from '@sentry/hono/node'; +import { Hono } from 'hono'; + +type AppEnv = { Bindings: { E2E_TEST_DSN: string }; Variables: { user: string } }; + +const dsn = 'https://public@dsn.ingest.sentry.io/1337'; + +const app = new Hono(); +app.use(sentryNode(app)); +app.use(sentryBun(app, { dsn })); +app.use(sentryDeno(app, { dsn })); +app.use(sentryCloudflare(app, env => ({ dsn: env.E2E_TEST_DSN }))); + +const api = app.basePath('/api'); +api.use(sentryNode(api)); diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/tsconfig.typecheck.json b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tsconfig.typecheck.json new file mode 100644 index 000000000000..9131a4be8ad3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tsconfig.typecheck.json @@ -0,0 +1,5 @@ +{ + // The rest of `src` needs Node, Bun and Deno types, which this app does not install. + "extends": "./tsconfig.json", + "include": ["src/*.typecheck.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/hono-4/package.json b/dev-packages/e2e-tests/test-applications/hono-4/package.json index 17467a9fe90e..008be0636b19 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/package.json +++ b/dev-packages/e2e-tests/test-applications/hono-4/package.json @@ -8,7 +8,8 @@ "dev:bun": "bun run dist/entry.bun.js", "dev:deno": "deno run --allow-net --allow-env --allow-read --preload=npm:@sentry/deno/import src/entry.deno.ts", "dev:cloudflare": "wrangler dev --config ./dist/hono_4/wrangler.json --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", - "test:build": "pnpm install", + "test:build": "pnpm install && pnpm typecheck", + "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", "test": "TEST_ENV=production playwright test", "test:assert": "pnpm test:assert:node", "test:assert:node": "RUNTIME=node pnpm test", @@ -34,9 +35,6 @@ "vite": "^8.1.5", "wrangler": "^4.114.0" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "variants": [ { diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/honoMiddleware.typecheck.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/honoMiddleware.typecheck.ts new file mode 100644 index 000000000000..396d90942d11 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/honoMiddleware.typecheck.ts @@ -0,0 +1,12 @@ +// Only `tsc` in `test:build` reads this file. Nothing runs it. +import * as Sentry from '@sentry/node'; +import { Hono } from 'hono'; + +type AppEnv = { Bindings: { E2E_TEST_DSN: string }; Variables: { user: string } }; + +const app = new Hono(); +app.use(Sentry.honoMiddleware(app)); +app.use('*', Sentry.honoMiddleware(app)); + +const api = app.basePath('/api'); +api.use(Sentry.honoMiddleware(api)); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tsconfig.typecheck.json b/dev-packages/e2e-tests/test-applications/hono-4/tsconfig.typecheck.json new file mode 100644 index 000000000000..9131a4be8ad3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/tsconfig.typecheck.json @@ -0,0 +1,5 @@ +{ + // The rest of `src` needs Node, Bun and Deno types, which this app does not install. + "extends": "./tsconfig.json", + "include": ["src/*.typecheck.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/package.json b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/package.json index 1779f0651392..db70cefd152d 100644 --- a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/package.json +++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/package.json @@ -43,8 +43,5 @@ "typescript": "^5.2.2", "vite": "^6.2.4", "vite-tsconfig-paths": "^4.3.1" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/lighthouse-react/package.json b/dev-packages/e2e-tests/test-applications/lighthouse-react/package.json index 5b7b01ee4d4a..b0a9d2858d57 100644 --- a/dev-packages/e2e-tests/test-applications/lighthouse-react/package.json +++ b/dev-packages/e2e-tests/test-applications/lighthouse-react/package.json @@ -33,8 +33,5 @@ "@vitejs/plugin-react-swc": "^3.7.0", "typescript": "~5.4.0", "vite": "^5.4.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11-static/package.json b/dev-packages/e2e-tests/test-applications/nestjs-11-static/package.json index 19fa66b0e30b..36a8ae821650 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-11-static/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-11-static/package.json @@ -40,8 +40,5 @@ "ts-loader": "^9.4.3", "tsconfig-paths": "^4.2.0", "typescript": "~5.0.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/package.json b/dev-packages/e2e-tests/test-applications/nestjs-11/package.json index 132c754e29d1..97386efde9bf 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-11/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-11/package.json @@ -40,8 +40,5 @@ "ts-loader": "^9.4.3", "tsconfig-paths": "^4.2.0", "typescript": "~5.0.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-12/package.json b/dev-packages/e2e-tests/test-applications/nestjs-12/package.json index f17e3d5e5950..724a30b63c0f 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-12/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-12/package.json @@ -42,8 +42,5 @@ "ts-loader": "^9.4.3", "tsconfig-paths": "^4.2.0", "typescript": "~5.9.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-8/package.json b/dev-packages/e2e-tests/test-applications/nestjs-8/package.json index efc2ee091aea..c1b77a1b000b 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-8/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-8/package.json @@ -40,8 +40,5 @@ "ts-loader": "^9.4.3", "tsconfig-paths": "^4.2.0", "typescript": "~5.0.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/package.json b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/package.json index 04bf0b8024f7..e20dc9ccb320 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/package.json @@ -42,8 +42,5 @@ "ts-loader": "^9.4.3", "tsconfig-paths": "^4.2.0", "typescript": "~5.0.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/package.json b/dev-packages/e2e-tests/test-applications/nestjs-basic/package.json index d5763452a60c..76340f92e80a 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/package.json @@ -42,9 +42,6 @@ "tsconfig-paths": "^4.2.0", "typescript": "~5.0.0" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "variants": [ { diff --git a/dev-packages/e2e-tests/test-applications/nestjs-bullmq/package.json b/dev-packages/e2e-tests/test-applications/nestjs-bullmq/package.json index 4700b59ba28b..ee5c97859d76 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-bullmq/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-bullmq/package.json @@ -29,8 +29,5 @@ "@nestjs/schematics": "^11.0.0", "@types/node": "^18.19.1", "typescript": "~5.0.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/package.json b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/package.json index c417e851a114..74adc5bda089 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/package.json @@ -39,8 +39,5 @@ "ts-loader": "^9.4.3", "tsconfig-paths": "^4.2.0", "typescript": "~5.0.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-fastify/package.json b/dev-packages/e2e-tests/test-applications/nestjs-fastify/package.json index c145694fc9ab..170b04e67123 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-fastify/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-fastify/package.json @@ -40,8 +40,5 @@ "ts-loader": "^9.4.3", "tsconfig-paths": "^4.2.0", "typescript": "^5.1.3" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-graphql/package.json b/dev-packages/e2e-tests/test-applications/nestjs-graphql/package.json index 563bc219fdc7..d6febbcfbbd5 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-graphql/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-graphql/package.json @@ -42,8 +42,5 @@ "ts-loader": "^9.4.3", "tsconfig-paths": "^4.2.0", "typescript": "~5.0.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-microservices/package.json b/dev-packages/e2e-tests/test-applications/nestjs-microservices/package.json index ab8af3f728a4..7e9856b2beaa 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-microservices/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-microservices/package.json @@ -26,8 +26,5 @@ "@nestjs/schematics": "^11.0.0", "@types/node": "^18.19.1", "typescript": "~5.0.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-webpack/nest-cli.json b/dev-packages/e2e-tests/test-applications/nestjs-webpack/nest-cli.json new file mode 100644 index 000000000000..c6e6d9356b5d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-webpack/nest-cli.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true, + "webpack": true, + "webpackConfigPath": "webpack.config.js" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-webpack/package.json b/dev-packages/e2e-tests/test-applications/nestjs-webpack/package.json new file mode 100644 index 000000000000..3dd2c2c44dad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-webpack/package.json @@ -0,0 +1,33 @@ +{ + "name": "nestjs-webpack", + "description": "ensure @sentry/nestjs/webpack works with the Nest CLI webpack builder", + "version": "0.0.1", + "private": true, + "scripts": { + "build": "nest build", + "start": "node dist/main", + "clean": "npx rimraf node_modules dist pnpm-lock.yaml", + "test": "playwright test", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@sentry/nestjs": "file:../../packed/sentry-nestjs-packed.tgz", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@playwright/test": "1.64.1", + "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@types/express": "^4.17.17", + "@types/node": "^18.19.1", + "ts-loader": "^9.4.3", + "tsconfig-paths": "^4.2.0", + "typescript": "~5.0.0" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-webpack/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nestjs-webpack/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-webpack/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nestjs-webpack/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/nestjs-webpack/pnpm-workspace.yaml new file mode 100644 index 000000000000..50ece95aba3b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-webpack/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +minimumReleaseAge: 0 +overrides: + 'minimatch': '10.0.1' diff --git a/dev-packages/e2e-tests/test-applications/nestjs-webpack/src/app.controller.ts b/dev-packages/e2e-tests/test-applications/nestjs-webpack/src/app.controller.ts new file mode 100644 index 000000000000..122b769162cf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-webpack/src/app.controller.ts @@ -0,0 +1,23 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { flush } from '@sentry/nestjs'; +import { AppService } from './app.service'; + +@Controller() +export class AppController { + constructor(private readonly appService: AppService) {} + + @Get('test-transaction') + testTransaction() { + return this.appService.testTransaction(); + } + + @Get('test-exception/:id') + async testException(@Param('id') id: string) { + return this.appService.testException(id); + } + + @Get('flush') + async flush() { + await flush(); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-webpack/src/app.module.ts b/dev-packages/e2e-tests/test-applications/nestjs-webpack/src/app.module.ts new file mode 100644 index 000000000000..059d719456fb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-webpack/src/app.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; +import { SentryGlobalFilter, SentryModule } from '@sentry/nestjs/setup'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; + +@Module({ + imports: [SentryModule.forRoot()], + controllers: [AppController], + providers: [ + AppService, + { + provide: APP_FILTER, + useClass: SentryGlobalFilter, + }, + ], +}) +export class AppModule {} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-webpack/src/app.service.ts b/dev-packages/e2e-tests/test-applications/nestjs-webpack/src/app.service.ts new file mode 100644 index 000000000000..715eaf8bdac1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-webpack/src/app.service.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import * as Sentry from '@sentry/nestjs'; + +@Injectable() +export class AppService { + testTransaction() { + Sentry.startSpan({ name: 'test-span' }, () => { + Sentry.startSpan({ name: 'child-span' }, () => {}); + }); + } + + testException(id: string) { + throw new Error(`This is an exception with id ${id}`); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-webpack/src/instrument.ts b/dev-packages/e2e-tests/test-applications/nestjs-webpack/src/instrument.ts new file mode 100644 index 000000000000..f1f4de865435 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-webpack/src/instrument.ts @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/nestjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, +}); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-webpack/src/main.ts b/dev-packages/e2e-tests/test-applications/nestjs-webpack/src/main.ts new file mode 100644 index 000000000000..71ce685f4d61 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-webpack/src/main.ts @@ -0,0 +1,15 @@ +// Import this first +import './instrument'; + +// Import other modules +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; + +const PORT = 3030; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + await app.listen(PORT); +} + +bootstrap(); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-webpack/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nestjs-webpack/start-event-proxy.mjs new file mode 100644 index 000000000000..6d02c6d5e3fe --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-webpack/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nestjs-webpack', +}); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-webpack/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-webpack/tests/errors.test.ts new file mode 100644 index 000000000000..cd5d05c3c2bf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-webpack/tests/errors.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +const APP_NAME = 'nestjs-webpack'; + +test('Sends an exception with the release injected by the webpack plugin', async ({ baseURL }) => { + const errorEventPromise = waitForError(APP_NAME, event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + const response = await fetch(`${baseURL}/test-exception/123`); + expect(response.status).toBe(500); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.http.nestjs.global_filter', + }); + expect(errorEvent.transaction).toEqual('GET /test-exception/:id'); + + // Set by the release injection of `sentryWebpackPlugin` from `@sentry/nestjs/webpack`. + expect(errorEvent.release).toBe('nestjs-webpack-e2e'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-webpack/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-webpack/tests/transactions.test.ts new file mode 100644 index 000000000000..6eb0e0b03480 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-webpack/tests/transactions.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; + +const APP_NAME = 'nestjs-webpack'; + +test('Sends streamed spans for an API route', async ({ baseURL }) => { + const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /test-transaction'); + + await fetch(`${baseURL}/test-transaction`); + + const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment)!; + + expect(segmentSpan).toMatchObject({ + name: 'GET /test-transaction', + is_segment: true, + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'http.server' }, + 'http.route': { type: 'string', value: '/test-transaction' }, + 'http.response.status_code': { type: 'integer', value: 200 }, + }), + }); + + expect(spans.find(span => span.name === 'test-span')).toMatchObject({ is_segment: false }); + expect(spans.find(span => span.name === 'child-span')).toMatchObject({ is_segment: false }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-webpack/tsconfig.build.json b/dev-packages/e2e-tests/test-applications/nestjs-webpack/tsconfig.build.json new file mode 100644 index 000000000000..26c30d4eddf2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-webpack/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist"] +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-webpack/tsconfig.json b/dev-packages/e2e-tests/test-applications/nestjs-webpack/tsconfig.json new file mode 100644 index 000000000000..cf79f029c781 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-webpack/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": false, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "moduleResolution": "Node16" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-webpack/webpack.config.js b/dev-packages/e2e-tests/test-applications/nestjs-webpack/webpack.config.js new file mode 100644 index 000000000000..f1a87303328f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-webpack/webpack.config.js @@ -0,0 +1,18 @@ +// Extends the Nest CLI's own webpack config (`nest build` with `"webpack": true` in nest-cli.json) +// with the Sentry plugin from `@sentry/nestjs/webpack`. The injected release name shows up on +// every event, which is how the tests know the plugin ran. No auth, upload or telemetry. +const { sentryWebpackPlugin } = require('@sentry/nestjs/webpack'); + +module.exports = function (options) { + return { + ...options, + plugins: [ + ...options.plugins, + sentryWebpackPlugin({ + telemetry: false, + sourcemaps: { disable: true }, + release: { name: 'nestjs-webpack-e2e', create: false, finalize: false }, + }), + ], + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/nestjs-websockets/package.json b/dev-packages/e2e-tests/test-applications/nestjs-websockets/package.json index 143c0cca0573..9e500e740e84 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-websockets/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-websockets/package.json @@ -26,8 +26,5 @@ "@types/node": "^18.19.1", "socket.io-client": "^4.0.0", "typescript": "~5.0.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules-decorator/package.json b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules-decorator/package.json index d6a95126436d..967045d49e4e 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules-decorator/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules-decorator/package.json @@ -38,8 +38,5 @@ "ts-loader": "^9.4.3", "tsconfig-paths": "^4.2.0", "typescript": "~5.0.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/package.json b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/package.json index e903d6a38447..27fed5b9aa10 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/package.json @@ -38,8 +38,5 @@ "ts-loader": "^9.4.3", "tsconfig-paths": "^4.2.0", "typescript": "~5.0.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/package.json b/dev-packages/e2e-tests/test-applications/nextjs-14/package.json index 98db0c4ec798..9a3aa45dc92b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/package.json @@ -27,9 +27,6 @@ "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "@sentry/core": "file:../../packed/sentry-core-packed.tgz" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "optionalVariants": [ { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/middleware.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/middleware.ts new file mode 100644 index 000000000000..2273819a23fc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/middleware.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/nextjs'; +import { NextResponse, type NextRequest } from 'next/server'; + +export async function middleware(request: NextRequest) { + return (await Sentry.handleTunnelRequestIfMatched(request)) ?? NextResponse.next(); +} + +export const config = { + // The managed tunnel path is one segment of up to 7 characters, derived from the DSN. + matcher: '/:path([a-z0-9]{1,7})', +}; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/package.json index 318d05a6185f..576cd662335d 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/package.json @@ -24,7 +24,7 @@ "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" }, - "volta": { - "extends": "../../package.json" + "sentryTest": { + "runner": "namespace-profile-sentry-javascript" } } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/pages/api/tunnel.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/pages/api/tunnel.ts new file mode 100644 index 000000000000..6077b1029767 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/pages/api/tunnel.ts @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/nextjs'; +import type { NextApiRequest, NextApiResponse } from 'next'; + +// The tunnel streams the raw body, so Next must not parse it first. +export const config = { api: { bodyParser: false } }; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + await Sentry.handleTunnelRequest(req, res, { allowedDsns: ['http://public@localhost:3031/1337'] }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.edge.config.ts index 0ef32107efff..9a967644e8b1 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.edge.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.edge.config.ts @@ -9,4 +9,8 @@ Sentry.init({ // We are doing a lot of events at once in this test bufferSize: 1000, }, + // Lets the middleware tunnel envelopes for the event proxy's DSN. + integrations: [ + Sentry.serveTunnelIntegration({ allowedDsns: ['http://public@localhost:3031/1337'], basePath: '/my-app' }), + ], }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tests/tunnel.test.ts new file mode 100644 index 000000000000..ef505967ddb7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tests/tunnel.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; +import { randomUUID } from 'node:crypto'; + +// The event proxy's DSN, so the forwarded envelopes arrive at the proxy. +const BROWSER_DSN = 'http://public@localhost:3031/1337'; +// `getTunnelPath(BROWSER_DSN)` +const TUNNEL_PATH = '/qpo967'; + +/** + * An error envelope as the browser SDK sends it through the tunnel. + */ +function browserEnvelope(message: string): string { + const eventId = randomUUID().replace(/-/g, ''); + + return [ + JSON.stringify({ dsn: BROWSER_DSN, event_id: eventId }), + JSON.stringify({ type: 'event' }), + JSON.stringify({ event_id: eventId, exception: { values: [{ type: 'Error', value: message }] } }), + ].join('\n'); +} + +test('Forwards a tunnel request from a Pages Router API route without a body parser', async ({ request }) => { + const errorEventPromise = waitForError('nextjs-15-basepath', errorEvent => { + return errorEvent.exception?.values?.[0]?.value === 'Tunneled through a Pages Router API route'; + }); + + const response = await request.post('/my-app/api/tunnel', { + data: browserEnvelope('Tunneled through a Pages Router API route'), + }); + + expect(response.status()).toBe(200); + await errorEventPromise; +}); + +test('Forwards a tunnel request from middleware under the basePath', async ({ request }) => { + const errorEventPromise = waitForError('nextjs-15-basepath', errorEvent => { + return errorEvent.exception?.values?.[0]?.value === 'Tunneled through middleware'; + }); + + const response = await request.post(`/my-app${TUNNEL_PATH}`, { + data: browserEnvelope('Tunneled through middleware'), + }); + + expect(response.status()).toBe(200); + await errorEventPromise; +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json index 998a4976ad3a..59d044708b93 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json @@ -25,7 +25,7 @@ "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" }, - "volta": { - "extends": "../../package.json" + "sentryTest": { + "runner": "namespace-profile-sentry-javascript" } } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-t3/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15-t3/package.json index db01367495e8..115787f8fbbd 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15-t3/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-t3/package.json @@ -44,7 +44,7 @@ "ct3aMetadata": { "initVersion": "7.47.1" }, - "volta": { - "extends": "../../package.json" + "sentryTest": { + "runner": "namespace-profile-sentry-javascript" } } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json index 30da1c0e8747..fb1cad71c57e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json @@ -30,10 +30,8 @@ "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { + "runner": "namespace-profile-sentry-javascript", "optionalVariants": [ { "build-command": "pnpm test:build-latest", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json index 855491173d4f..eba44d444994 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json @@ -42,10 +42,8 @@ "eslint-config-next": "^16", "typescript": "^5" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { + "runner": "namespace-profile-sentry-javascript", "variants": [ { "build-command": "pnpm test:build-webpack", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/package.json index ce6fd059b894..df76d8284b31 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/package.json @@ -36,9 +36,6 @@ "eslint-config-next": "^16", "typescript": "^5" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "variants": [ { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-static/package.json index 50f57e8ca472..a8a2366e2445 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-static/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/package.json @@ -40,7 +40,7 @@ "eslint-config-next": "^16", "typescript": "^5" }, - "volta": { - "extends": "../../package.json" + "sentryTest": { + "runner": "namespace-profile-sentry-javascript" } } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/package.json index 3e6d5d60e233..24c37deeba72 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/package.json @@ -42,10 +42,8 @@ "eslint-config-next": "^16", "typescript": "^5" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { + "runner": "namespace-profile-sentry-javascript", "variants": [ { "build-command": "pnpm test:build-webpack", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/package.json index 39da00835495..9870a3f02d64 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/package.json @@ -40,10 +40,8 @@ "eslint-config-next": "^16", "typescript": "^5" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { + "runner": "namespace-profile-sentry-javascript", "variants": [ { "build-command": "pnpm test:build-webpack", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/package.json index 257b5105bafd..6e0d8bb66455 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/package.json @@ -27,8 +27,5 @@ "@types/react": "^19", "@types/react-dom": "^19", "typescript": "^5" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/package.json index c21a67f1b492..2b01d1438956 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/package.json @@ -29,9 +29,6 @@ "@types/react-dom": "^19", "typescript": "^5" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "variants": [ { 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 adbc49ec455c..49fab7eb7fe7 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 @@ -41,7 +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", + "@sentry/conventions": "0.29.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -50,10 +50,8 @@ "typescript": "^5", "wrangler": "^4.61.0" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { + "runner": "namespace-profile-sentry-javascript", "variants": [ { "build-command": "pnpm test:build-webpack", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-userfeedback/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-userfeedback/package.json index 5b72e19f636f..313418cda7c3 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-userfeedback/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-userfeedback/package.json @@ -26,8 +26,5 @@ "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "@tailwindcss/postcss": "^4", "tailwindcss": "^4" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index af9b90169603..75071f25d027 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -22,14 +22,14 @@ "test:assert": "pnpm test:prod && pnpm test:dev", "test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack", "test:build:cloudflare": "pnpm install && opennextjs-cloudflare build --config wrangler.opennext.jsonc", - "test:build-latest:cloudflare": "pnpm install && pnpm add next@16.3 && opennextjs-cloudflare build --config wrangler.opennext.jsonc", + "test:build-latest:cloudflare": "pnpm install && pnpm add next@latest && opennextjs-cloudflare build --config wrangler.opennext.jsonc", "start:cloudflare": "opennextjs-cloudflare preview --config wrangler.opennext.jsonc --var \"E2E_TEST_DSN:$E2E_TEST_DSN\"", "test:build:vinext": "pnpm install && pnpm add react@19.3.1 react-dom@19.3.1 && npm pkg set type=module && vinext build", "test:assert:vinext": "VINEXT=1 pnpm test:prod" }, "//": "Pin `ioredis` to 5.10.1: it's the last version before the driver publishes its own native diagnostics channels; orchestrion's config covers `ioredis <5.11.0`.", "dependencies": { - "@opennextjs/cloudflare": "^1.14.9", + "@opennextjs/cloudflare": "^1.20.12", "@sentry/bun": "file:../../packed/sentry-bun-packed.tgz", "@sentry/deno": "file:../../packed/sentry-deno-packed.tgz", "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", @@ -63,10 +63,8 @@ "vite": "^8.3.0", "wrangler": "^4.61.0" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { + "runner": "namespace-profile-sentry-javascript", "variants": [ { "build-command": "pnpm test:build-webpack", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json index 43cea1078cf2..2f9889c336f1 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json @@ -28,10 +28,8 @@ "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "ts-node": "10.9.1" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { + "runner": "namespace-profile-sentry-javascript", "variants": [ { "build-command": "pnpm test:build-15", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-orpc/package.json b/dev-packages/e2e-tests/test-applications/nextjs-orpc/package.json index bf9d0b70a41a..fa5eef6fd840 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-orpc/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-orpc/package.json @@ -39,9 +39,6 @@ "typescript": "^5.5.3", "zod": "^3.24.2" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "optional": true } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-otlp/package.json b/dev-packages/e2e-tests/test-applications/nextjs-otlp/package.json index 4bc498a3e49d..57dbccddf186 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-otlp/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/package.json @@ -42,9 +42,6 @@ "eslint-config-next": "^16", "typescript": "^5" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "variants": [ { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json index fb3aeaf3399f..d3e2f51b92ff 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json @@ -29,10 +29,8 @@ "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "ts-node": "10.9.1" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { + "runner": "namespace-profile-sentry-javascript", "variants": [ { "build-command": "pnpm test:build-15", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/package.json index 2ac8a40dc26a..993eef30d1ee 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/package.json @@ -22,8 +22,5 @@ "@types/react": "^19", "@types/react-dom": "^19", "ts-node": "10.9.1" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs b/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs index 53b80d309a5b..c5c0fbd3d966 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs @@ -2,7 +2,8 @@ import * as Sentry from '@sentry/nitro'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - dsn: process.env.E2E_TEST_DSN, + dsn: process.env.E2E_TEST_TUNNEL ? 'http://public@localhost:3031/1337' : process.env.E2E_TEST_DSN, tunnel: `http://localhost:3031/`, // proxy server + integrations: process.env.E2E_TEST_TUNNEL ? [Sentry.serveTunnelIntegration()] : [], tracesSampleRate: 1, }); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/package.json b/dev-packages/e2e-tests/test-applications/nitro-3/package.json index 82836a613593..05907e35b114 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nitro-3/package.json @@ -9,7 +9,10 @@ "clean": "npx rimraf node_modules pnpm-lock.yaml .output", "test": "playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test" + "test:assert": "pnpm test", + "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm build", + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app playwright test tunnel", + "test:assert:tunnel-basepath-dev": "TEST_ENV=development E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app playwright test tunnel" }, "dependencies": { "@sentry/browser": "latest || *", @@ -18,13 +21,24 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", - "@sentry/conventions": "0.27.0", + "@sentry/conventions": "0.29.0", "@sentry/core": "latest || *", "nitro": "^3.0.260522-beta", "rolldown": "latest", "vite": "latest" }, - "volta": { - "extends": "../../package.json" + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath", + "label": "nitro-3 (tunnel-basepath)" + }, + { + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-basepath-dev", + "label": "nitro-3 (tunnel-basepath-dev)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs index 31f2b913b58b..62bc93244137 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs @@ -1,7 +1,10 @@ import { getPlaywrightConfig } from '@sentry-internal/test-utils'; const config = getPlaywrightConfig({ - startCommand: `pnpm start`, + startCommand: + process.env.TEST_ENV === 'development' + ? `PORT=3030 NODE_OPTIONS='--import ./instrument.mjs' pnpm exec vite dev --port 3030` + : `pnpm start`, }); export default config; diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts index 92d8f80c3756..0120ae6ce24e 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts @@ -1,6 +1,11 @@ -import { defineHandler, getQuery, setResponseHeader } from 'nitro/h3'; +import { defineHandler, getQuery, HTTPError, setResponseHeader } from 'nitro/h3'; export default defineHandler(event => { + // The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. + if (process.env.E2E_TEST_TUNNEL && event.req.method === 'POST' && event.req.headers.get('x-auth') !== 'ok') { + throw new HTTPError({ status: 401, statusText: 'blocked by user auth' }); + } + setResponseHeader(event, 'x-sentry-test-middleware', 'executed'); const query = getQuery(event); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts b/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts index d27d0ba1763a..d09a43e411ed 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts +++ b/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts @@ -1,10 +1,18 @@ import * as Sentry from '@sentry/browser'; +declare const __E2E_TEST_TUNNEL__: boolean; +declare const __E2E_TEST_BASE_PATH__: string; + // Let's us test trace propagation Sentry.init({ environment: 'qa', - dsn: 'https://public@dsn.ingest.sentry.io/1337', - tunnel: 'http://localhost:3031/', // proxy server + dsn: __E2E_TEST_TUNNEL__ ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', + // Plain Nitro has no client SDK to pass `baseURL` on, so the browser names it. The server picks it up itself. + tunnel: __E2E_TEST_TUNNEL__ + ? __E2E_TEST_BASE_PATH__ + ? { basePath: __E2E_TEST_BASE_PATH__ } + : true + : 'http://localhost:3031/', // proxy server integrations: [Sentry.browserTracingIntegration()], tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts new file mode 100644 index 000000000000..9d7cfadaeeb6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from '@playwright/test'; +import { + isServerSpanAt, + makeClientReportEnvelope, + waitForError, + watchForTunnelSpan, +} from '@sentry-internal/test-utils'; +import { getTunnelPath } from '@sentry/core'; +import { connect } from 'node:net'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const BASE_PATH = process.env.E2E_TEST_BASE_PATH ?? ''; +const TUNNEL_PATH = new RegExp(`^${BASE_PATH}/[a-z0-9]{1,7}$`); + +test('answers the managed tunnel before the app auth middleware', async ({ page }) => { + // The variant's auth middleware rejects every other POST. + expect((await page.request.post(`${BASE_PATH}/api/anything`, { data: 'x' })).status()).toBe(401); + + const raceTunnelSpan = watchForTunnelSpan('nitro-3', isServerSpanAt(TUNNEL_PATH)); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto(`${BASE_PATH}/`); + + expect((await tunnelResponsePromise).status()).toBe(200); + expect( + await raceTunnelSpan(isServerSpanAt(`${BASE_PATH}/api/test-transaction`), () => + page.request.get(`${BASE_PATH}/api/test-transaction`), + ), + ).toBe('anchor'); +}); + +test('answers a request to the managed tunnel itself', async ({ request }) => { + const dsn = 'http://public@localhost:3031/1337'; + const response = await request.post(`${BASE_PATH}${getTunnelPath(dsn)}`, { + data: makeClientReportEnvelope(dsn), + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }); + + expect(response.status()).toBe(200); +}); + +test('does not report a client that drops the connection before the envelope header as an error', async ({ + request, +}) => { + const dsn = 'http://public@localhost:3031/1337'; + const firstErrorPromise = waitForError('nitro-3', () => true); + + // Part of the envelope header line, without its newline, then the connection drops. + await new Promise(resolve => { + const socket = connect(3030, 'localhost', () => { + socket.write( + `POST ${BASE_PATH}${getTunnelPath(dsn)} HTTP/1.1\r\nHost: localhost:3030\r\nContent-Type: application/x-sentry-envelope\r\nContent-Length: 1000000\r\n\r\n`, + ); + socket.write(JSON.stringify({ dsn, padding: 'x'.repeat(1000) }).slice(0, 500)); + setTimeout(() => { + socket.destroy(); + resolve(); + }, 300); + }); + }); + + // Errors are sent in order, so one from the dropped request would arrive before this one. + await request.get(`${BASE_PATH}/api/test-error`); + + const firstError = await firstErrorPromise; + expect(firstError.exception?.values?.[0]?.value).toBe('This is a test error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts b/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts index d488f8298777..b7c6de66d47c 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts @@ -3,12 +3,17 @@ import { nitro } from 'nitro/vite'; import { defineConfig } from 'vite'; export default defineConfig({ + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + __E2E_TEST_BASE_PATH__: JSON.stringify(process.env.E2E_TEST_BASE_PATH ?? ''), + }, plugins: [ nitro( // FIXME: Nitro plugin has a type issue // @ts-expect-error withSentryConfig({ serverDir: './server', + ...(process.env.E2E_TEST_BASE_PATH && { baseURL: `${process.env.E2E_TEST_BASE_PATH}/` }), }), ), ], diff --git a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/package.json index ae065284ff58..9f7d8ae4fa75 100644 --- a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/package.json +++ b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/package.json @@ -32,8 +32,5 @@ "label": "node-anthropic-send-to-sentry (latest)" } ] - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/src/app.ts b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/src/app.ts index 469545162364..693f1ef4f3b3 100644 --- a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/src/app.ts @@ -89,7 +89,6 @@ app.get('/chat', async (_req, res, next) => { const message = await client.messages.create({ model: MODEL, max_tokens: 32, - temperature: 0, system: SYSTEM, messages: [{ role: 'user', content: CHAT_PROMPT }], }); @@ -105,7 +104,6 @@ app.get('/chat-stream', async (_req, res, next) => { const stream = await client.messages.create({ model: MODEL, max_tokens: 32, - temperature: 0, system: SYSTEM, messages: [{ role: 'user', content: CHAT_PROMPT }], stream: true, @@ -131,7 +129,6 @@ app.get('/stream-helper', async (_req, res, next) => { .stream({ model: MODEL, max_tokens: 32, - temperature: 0, system: SYSTEM, messages: [{ role: 'user', content: CHAT_PROMPT }], }) diff --git a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/tests/send-to-sentry.test.ts b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/tests/send-to-sentry.test.ts index 44831a6d1a59..e7a135c2368c 100644 --- a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/tests/send-to-sentry.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/tests/send-to-sentry.test.ts @@ -96,7 +96,6 @@ test('Sends a message to Sentry as a gen_ai.chat span under the request span', a expectModelCallAttributes(span, attributes); expect(attributes['gen_ai.usage.input_tokens']).toBeGreaterThan(0); - expect(attributes['gen_ai.request.temperature']).toBe(0); expect(attributes['gen_ai.response.streaming']).toBeUndefined(); expectRecordedConversation(attributes); diff --git a/dev-packages/e2e-tests/test-applications/node-esbuild/package.json b/dev-packages/e2e-tests/test-applications/node-esbuild/package.json index 856891177c42..11eb547f133d 100644 --- a/dev-packages/e2e-tests/test-applications/node-esbuild/package.json +++ b/dev-packages/e2e-tests/test-applications/node-esbuild/package.json @@ -17,8 +17,5 @@ "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "graphql": "16.9.0", "esbuild": "0.28.2" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/node-eve/package.json b/dev-packages/e2e-tests/test-applications/node-eve/package.json index e41b30dc7fb4..bf4389573a07 100644 --- a/dev-packages/e2e-tests/test-applications/node-eve/package.json +++ b/dev-packages/e2e-tests/test-applications/node-eve/package.json @@ -27,12 +27,12 @@ "ai": "^7.0.82", "dataloader": "^2.2.3", "eve": "^0.63.0", - "zod": "4.5.4" + "zod": "4.6.6" }, "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", - "@sentry/conventions": "0.27.0", + "@sentry/conventions": "0.29.0", "@sentry/core": "file:../../packed/sentry-core-packed.tgz", "@types/node": "24.x", "typescript": "~5.9.0" @@ -40,9 +40,6 @@ "engines": { "node": "24.x" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "optional": true, "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/node-eve/tests/eve.test.ts b/dev-packages/e2e-tests/test-applications/node-eve/tests/eve.test.ts index de45eacd39d2..60998bf9ff26 100644 --- a/dev-packages/e2e-tests/test-applications/node-eve/tests/eve.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-eve/tests/eve.test.ts @@ -53,7 +53,7 @@ test('captures Vercel AI agent spans (invoke_agent, generate_content, execute_to const generateContent = traceSpans.find(span => getSpanOp(span) === 'gen_ai.generate_content'); const executeTool = traceSpans.find(span => getSpanOp(span) === 'gen_ai.execute_tool'); - expect(invokeAgent?.attributes?.['sentry.origin']?.value).toBe('auto.vercelai.channel'); + expect(invokeAgent?.attributes?.['sentry.origin']?.value).toBe('auto.ai.vercelai'); expect(invokeAgent?.attributes?.['gen_ai.operation.name']?.value).toBe('invoke_agent'); expect(invokeAgent?.attributes?.['gen_ai.request.model']?.value).toBe('openai/gpt-4o-mini'); expect(invokeAgent?.attributes?.['gen_ai.provider.name']?.value).toBe('openrouter'); @@ -134,7 +134,7 @@ test('captures errors thrown inside an eve tool', async ({ baseURL }) => { type: 'Error', value: expect.stringContaining('Intentional eve tool failure'), mechanism: { - type: 'auto.vercelai.channel', + type: 'auto.ai.vercelai', handled: false, }, }, @@ -165,7 +165,7 @@ test('captures a gen_ai.decide span for a Jev call inside an eve tool', async ({ expect(decide?.name).toBe('decide typesafe-ai/jev'); expect(decide?.status).toBe('ok'); - expect(decide?.attributes?.[SENTRY_ORIGIN]?.value).toBe('auto.vercelai.channel'); + expect(decide?.attributes?.[SENTRY_ORIGIN]?.value).toBe('auto.ai.vercelai'); expect(decide?.attributes?.[GEN_AI_OPERATION_NAME]?.value).toBe('decide'); expect(decide?.attributes?.[GEN_AI_REQUEST_MODEL]?.value).toBe('typesafe-ai/jev'); expect(decide?.attributes?.[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(275); diff --git a/dev-packages/e2e-tests/test-applications/node-eve/tests/utils.ts b/dev-packages/e2e-tests/test-applications/node-eve/tests/utils.ts index 50542a116320..93c62a95cf88 100644 --- a/dev-packages/e2e-tests/test-applications/node-eve/tests/utils.ts +++ b/dev-packages/e2e-tests/test-applications/node-eve/tests/utils.ts @@ -31,7 +31,12 @@ export async function runAgentTurn(baseURL: string, message: string): Promise= 0.84 settles each request with `delivery.settled`; older versions end on `session.waiting` / `turn.failed` + if ( + buffer.includes('"type":"delivery.settled"') || + buffer.includes('"type":"session.waiting"') || + buffer.includes('"type":"turn.failed"') + ) { break; } } diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/package.json b/dev-packages/e2e-tests/test-applications/node-exports-test-app/package.json index a836b2b618ba..fc1b592adbe8 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/package.json +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/package.json @@ -22,8 +22,5 @@ "@sentry/bun": "file:../../packed/sentry-bun-packed.tgz", "@types/node": "^18.19.1", "typescript": "~5.0.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index 2821cf41f0a6..6bee27e997bc 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -55,7 +55,6 @@ const DEPENDENTS: Dependent[] = [ 'NODE_VERSION', 'childProcessIntegration', 'workerThreadsIntegration', - 'systemErrorIntegration', 'pinoIntegration', // Bun will get its own runtime metrics integration 'nodeRuntimeMetricsIntegration', diff --git a/dev-packages/e2e-tests/test-applications/node-express-esm-loader/package.json b/dev-packages/e2e-tests/test-applications/node-express-esm-loader/package.json index 914036976778..b7e349125d37 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-esm-loader/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express-esm-loader/package.json @@ -17,8 +17,5 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/node-express-esm-without-loader/package.json b/dev-packages/e2e-tests/test-applications/node-express-esm-without-loader/package.json index 60de9b4174d3..fb5517e65d30 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-esm-without-loader/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express-esm-without-loader/package.json @@ -16,8 +16,5 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/package.json b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/package.json index 111200dd2b3d..4dda82a346a3 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/package.json @@ -25,9 +25,6 @@ "@sentry/core": "file:../../packed/sentry-core-packed.tgz" }, "type": "module", - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "optional": true } diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json index ab20b660beef..b0d70c570d18 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json @@ -28,9 +28,6 @@ "@sentry/core": "file:../../packed/sentry-core-packed.tgz" }, "type": "module", - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "optional": true } diff --git a/dev-packages/e2e-tests/test-applications/node-express-otlp/package.json b/dev-packages/e2e-tests/test-applications/node-express-otlp/package.json index a63e7392a49d..70d0b7343d22 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-otlp/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express-otlp/package.json @@ -24,8 +24,5 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/node-express-send-to-sentry-static/package.json b/dev-packages/e2e-tests/test-applications/node-express-send-to-sentry-static/package.json index b7807eaa8d27..488ca4b8949c 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-send-to-sentry-static/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express-send-to-sentry-static/package.json @@ -20,9 +20,6 @@ "devDependencies": { "@playwright/test": "1.64.1" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "optional": true } diff --git a/dev-packages/e2e-tests/test-applications/node-express-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/node-express-send-to-sentry/package.json index d9979058c198..0ca6a0cb481c 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-send-to-sentry/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express-send-to-sentry/package.json @@ -20,9 +20,6 @@ "devDependencies": { "@playwright/test": "1.64.1" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "optional": true } diff --git a/dev-packages/e2e-tests/test-applications/node-express-static/package.json b/dev-packages/e2e-tests/test-applications/node-express-static/package.json index 3973ba323170..4e05d8429c08 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-static/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express-static/package.json @@ -26,9 +26,6 @@ "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "@sentry/core": "file:../../packed/sentry-core-packed.tgz" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "variants": [ { diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/package.json b/dev-packages/e2e-tests/test-applications/node-express-streaming/package.json index 34148aa75228..a96bd53edca4 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-streaming/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/package.json @@ -25,8 +25,5 @@ "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "@sentry/core": "file:../../packed/sentry-core-packed.tgz" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/package.json b/dev-packages/e2e-tests/test-applications/node-express-v5/package.json index 59080bbc325c..2bc47012a8ef 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-v5/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/package.json @@ -26,8 +26,5 @@ "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "@sentry/core": "file:../../packed/sentry-core-packed.tgz" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/node-express/package.json b/dev-packages/e2e-tests/test-applications/node-express/package.json index 6acc2b653186..adc4b406f5d4 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express/package.json @@ -26,9 +26,6 @@ "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "@sentry/core": "file:../../packed/sentry-core-packed.tgz" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "variants": [ { diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-3/package.json b/dev-packages/e2e-tests/test-applications/node-fastify-3/package.json index 2efbfa29641f..dc3953ddf526 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-3/package.json +++ b/dev-packages/e2e-tests/test-applications/node-fastify-3/package.json @@ -20,8 +20,5 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-4/package.json b/dev-packages/e2e-tests/test-applications/node-fastify-4/package.json index 85fc0c8528b6..d253b8dfe01f 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-4/package.json +++ b/dev-packages/e2e-tests/test-applications/node-fastify-4/package.json @@ -20,8 +20,5 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-5/package.json b/dev-packages/e2e-tests/test-applications/node-fastify-5/package.json index 05144509cb6e..fc96a360b6ec 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-5/package.json +++ b/dev-packages/e2e-tests/test-applications/node-fastify-5/package.json @@ -21,9 +21,6 @@ "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "variants": [ { diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-6/package.json b/dev-packages/e2e-tests/test-applications/node-fastify-6/package.json index dd231ca614f5..fc948455cba7 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-6/package.json +++ b/dev-packages/e2e-tests/test-applications/node-fastify-6/package.json @@ -21,9 +21,6 @@ "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "optional": true, "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/package.json b/dev-packages/e2e-tests/test-applications/node-firebase/package.json index 62179b36597b..9bab9dc4d32c 100644 --- a/dev-packages/e2e-tests/test-applications/node-firebase/package.json +++ b/dev-packages/e2e-tests/test-applications/node-firebase/package.json @@ -23,8 +23,5 @@ "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "firebase-tools": "^14.20.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/tests/functions.test.ts b/dev-packages/e2e-tests/test-applications/node-firebase/tests/functions.test.ts index 7accea39f1b1..46091b06fb29 100644 --- a/dev-packages/e2e-tests/test-applications/node-firebase/tests/functions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-firebase/tests/functions.test.ts @@ -31,7 +31,7 @@ test('should create one segment for an HTTP function', async () => { 'faas.trigger': { value: 'http.request', type: 'string' }, 'gcp.function.context.type': { value: 'firebase.function.http.request', type: 'string' }, 'sentry.kind': { value: 'server', type: 'string' }, - 'sentry.origin': { value: 'auto.firebase.functions', type: 'string' }, + 'sentry.origin': { value: 'auto.function.firebase', type: 'string' }, 'sentry.sample_rate': { value: expect.any(Number), type: 'integer' }, 'sentry.segment.name.source': { value: 'component', type: 'string' }, }), @@ -60,7 +60,7 @@ test('should send failed span when the function fails', async () => { expect.objectContaining({ type: 'Error', value: 'There is an error!', - mechanism: { type: 'auto.firebase.functions', handled: false }, + mechanism: { type: 'auto.function.firebase', handled: false }, }), ]); }); @@ -96,7 +96,7 @@ test('should create a document and trigger onDocumentCreated and another with au 'gcp.function.context.type': { value: `firebase.function.${trigger}`, type: 'string' }, 'sentry.kind': { value: 'server', type: 'string' }, 'sentry.op': { value: 'function.gcp', type: 'string' }, - 'sentry.origin': { value: 'auto.firebase.functions', type: 'string' }, + 'sentry.origin': { value: 'auto.function.firebase', type: 'string' }, 'sentry.sample_rate': { value: expect.any(Number), type: 'integer' }, 'sentry.segment.name.source': { value: 'component', type: 'string' }, }), diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/node-firebase/tests/spans.test.ts index dd4b11aa7586..cd9717ccdea0 100644 --- a/dev-packages/e2e-tests/test-applications/node-firebase/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-firebase/tests/spans.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; // The orchestrion spans are Sentry-native, so they carry no span-kind attribute (`sentry.kind`). -const origin = 'auto.firebase.firestore'; +const origin = 'auto.db.firebase.firestore'; function firestoreSpan(operation: string): unknown { return expect.objectContaining({ diff --git a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/.gitignore b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/.gitignore new file mode 100644 index 000000000000..2685f6ec088a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +.flue +*.tsbuildinfo +results.junit.xml +test-results +playwright-report diff --git a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/flue.config.ts b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/flue.config.ts new file mode 100644 index 000000000000..a31c8523a9a7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/flue.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from '@flue/runtime/config'; + +export default defineConfig({ + target: 'node', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/package.json new file mode 100644 index 000000000000..409652d5c19e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/package.json @@ -0,0 +1,49 @@ +{ + "name": "node-flue-send-to-sentry", + "description": "The Flue integration as a user runs it: a built Flue agent server, real tool-calling turns through OpenRouter, and the gen_ai spans read back from a real Sentry project", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "start": "NODE_OPTIONS='--import=@sentry/node/import' PORT=3030 node dist/server.mjs", + "test": "OPENROUTER_API_KEY=$E2E_OPENROUTER_API_KEY playwright test", + "clean": "npx rimraf node_modules dist pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:build-latest": "pnpm install && pnpm add @flue/runtime@latest @flue/vite@latest @flue/cli@latest && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@flue/runtime": "2.0.5", + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "dataloader": "^2.2.3", + "hono": "^4.7.0", + "valibot": "^1.5.0" + }, + "devDependencies": { + "@flue/cli": "2.0.5", + "@flue/vite": "2.0.5", + "@playwright/test": "1.64.1", + "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", + "@sentry/conventions": "0.27.0", + "@types/node": "24.x", + "sentry": "~0.44.1", + "typescript": "~5.9.0", + "vite": "^8.0.14" + }, + "engines": { + "node": "24.x" + }, + "volta": { + "extends": "../../package.json" + }, + "sentryTest": { + "optional": true, + "optionalVariants": [ + { + "build-command": "pnpm test:build-latest", + "label": "node-flue-send-to-sentry (latest)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/playwright.config.ts b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/playwright.config.ts new file mode 100644 index 000000000000..161e4b1c5d72 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from '@playwright/test'; + +const port = 3030; + +export default defineConfig({ + testDir: './tests', + // A model turn, then Sentry polls of up to 180s each. Spans become queryable within the first one. + timeout: 400_000, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: 0, + // Every test spends most of its time polling Sentry, so run them all at once. + workers: '100%', + reporter: process.env.CI ? [['list'], ['junit', { outputFile: 'results.junit.xml' }]] : 'list', + use: { + baseURL: `http://localhost:${port}`, + }, + webServer: { + command: 'pnpm start', + port, + stdout: 'pipe', + stderr: 'pipe', + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/pnpm-workspace.yaml new file mode 100644 index 000000000000..0e523a16e6f0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +allowBuilds: + '@google/genai': false + core-js-pure: false + esbuild: true + protobufjs: false +minimumReleaseAge: 0 diff --git a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/sentry-init.ts b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/sentry-init.ts new file mode 100644 index 000000000000..30f0b4c8cd49 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/sentry-init.ts @@ -0,0 +1,11 @@ +import { instrument } from '@flue/runtime'; +import * as Sentry from '@sentry/node'; + +// No tunnel: the spans go to the Sentry project behind `E2E_TEST_DSN`, where the tests read them back. +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + tracesSampleRate: 1.0, +}); + +instrument(Sentry.createFlueInstrumentation()); diff --git a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/src/agents/hello.ts b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/src/agents/hello.ts new file mode 100644 index 000000000000..9d218454a86e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/src/agents/hello.ts @@ -0,0 +1,50 @@ +'use agent'; +import { useModel, useTool } from '@flue/runtime'; +import * as Sentry from '@sentry/node'; +import * as v from 'valibot'; +import DataLoader from 'dataloader'; + +export function Hello() { + useModel('openrouter/anthropic/claude-haiku-4.5'); + + useTool({ + name: 'get_weather', + description: 'Get the current weather for a city.', + input: v.object({ city: v.string() }), + // Wrapped in a manual span: Flue runs the tool while the SDK's `execute_tool` span is active, + // so this should nest directly under it rather than landing beside it. + run: ({ data }) => + Sentry.startSpan( + { name: 'resolve-weather', attributes: { 'weather.source': 'static-table', 'weather.city': data.city } }, + () => { + return `It is 21 degrees and sunny in ${data.city}.`; + }, + ), + }); + + // Called from inside a tool on purpose: the dataloader span then lands under `execute_tool` in + // the agent's trace, which is what "captured alongside the AI spans" has to mean. + useTool({ + name: 'count_items', + description: 'Count items by loading them. Call this when the user asks to count items.', + input: v.object({}), + // Constructed per execution, like node-eve does: a module-level loader caches its keys, so a + // second call would skip the batch function and emit no span. + run: async () => { + const loader = new DataLoader(async keys => keys.map(key => key * 2)); + const doubled = await Promise.all([loader.load(1), loader.load(2), loader.load(3)]); + return `Loaded ${doubled.length} items: ${doubled.join(', ')}.`; + }, + }); + + useTool({ + name: 'fail_now', + description: 'Always throws an error. Call this when the user asks to trigger a failure.', + input: v.object({}), + run: () => { + throw new Error('Intentional flue tool failure'); + }, + }); + + return 'You are a helpful assistant. Use get_weather when asked about weather, count_items when asked to count items, and fail_now when asked to fail.'; +} diff --git a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/src/app.ts b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/src/app.ts new file mode 100644 index 000000000000..b54a3357e4ca --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/src/app.ts @@ -0,0 +1,10 @@ +import '../sentry-init.ts'; +import { createAgentRouter } from '@flue/runtime/routing'; +import { Hono } from 'hono'; +import { Hello } from './agents/hello.ts'; + +const app = new Hono(); + +app.route('/agents/hello', createAgentRouter(Hello)); + +export default app; diff --git a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/tests/dataloader.test.ts b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/tests/dataloader.test.ts new file mode 100644 index 000000000000..ba6a62fc8f7d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/tests/dataloader.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from '@playwright/test'; +import { SEARCH_ORIGIN } from '@sentry/conventions/attributes/search'; +import { CACHE_GET } from '@sentry/conventions/op'; +import { EVENT_POLLING_OPTIONS, fetchSpanAttributes } from '@sentry-internal/test-utils/cli'; +import { newInstanceId, runAgentTurn, waitForConversationTrace } from './utils'; + +// Orchestrion only hooks `dataloader` with the loader preloaded (`--import` in the start script). +test('sends orchestrion-instrumented dataloader spans in the same trace as the AI spans', async ({ baseURL }) => { + const conversationId = await runAgentTurn( + baseURL!, + newInstanceId('dataloader'), + 'Please call count_items to count the items.', + ); + + const { traceId, spans } = await waitForConversationTrace( + conversationId, + spans => + spans.some(span => span.description === 'execute_tool count_items') && spans.some(span => span.op === CACHE_GET), + ); + + const dataloaderSpan = spans.find(span => span.op === CACHE_GET); + await expect + .poll(() => fetchSpanAttributes(traceId, dataloaderSpan!.event_id!), EVENT_POLLING_OPTIONS) + .toMatchObject({ [SEARCH_ORIGIN]: 'auto.db.dataloader' }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/tests/errors.test.ts new file mode 100644 index 000000000000..aa4666c1a3bf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/tests/errors.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from '@playwright/test'; +import { SEARCH_GEN_AI__TOOL__NAME, SEARCH_SPAN__STATUS } from '@sentry/conventions/attributes/search'; +import { + EVENT_POLLING_OPTIONS, + fetchEvent, + fetchSpanAttributes, + findErrorInTrace, +} from '@sentry-internal/test-utils/cli'; +import type { TraceItem } from '@sentry-internal/test-utils/cli'; +import { newInstanceId, runAgentTurn, waitForConversationTrace } from './utils'; + +test('sends an error thrown inside a Flue tool and marks its span errored', async ({ baseURL }) => { + const conversationId = await runAgentTurn( + baseURL!, + newInstanceId('failure'), + 'Please call fail_now to trigger a failure.', + ); + + const { traceId, spans } = await waitForConversationTrace(conversationId, spans => + spans.some(span => span.description === 'execute_tool fail_now'), + ); + + let error: TraceItem | undefined; + await expect.poll(() => (error = findErrorInTrace(traceId)), EVENT_POLLING_OPTIONS).toBeDefined(); + await expect + .poll( + () => fetchEvent(error!.event_id!)?.entries.find(entry => entry.type === 'exception')?.data.values?.[0], + EVENT_POLLING_OPTIONS, + ) + .toMatchObject({ value: 'Intentional flue tool failure', mechanism: { type: 'auto.ai.flue' } }); + + const executeTool = spans.find(span => span.description === 'execute_tool fail_now'); + await expect + .poll(() => fetchSpanAttributes(traceId, executeTool!.event_id!), EVENT_POLLING_OPTIONS) + .toMatchObject({ [SEARCH_GEN_AI__TOOL__NAME]: 'fail_now', [SEARCH_SPAN__STATUS]: 'error' }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/tests/flue.test.ts b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/tests/flue.test.ts new file mode 100644 index 000000000000..6610ad009546 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/tests/flue.test.ts @@ -0,0 +1,105 @@ +import { expect, test } from '@playwright/test'; +import { + SEARCH_GEN_AI__AGENT__NAME, + SEARCH_GEN_AI__CONVERSATION__ID, + SEARCH_GEN_AI__COST__TOTAL_TOKENS, + SEARCH_GEN_AI__INPUT__MESSAGES, + SEARCH_GEN_AI__OPERATION__NAME, + SEARCH_GEN_AI__PROVIDER__NAME, + SEARCH_GEN_AI__TOOL__CALL__ARGUMENTS, + SEARCH_GEN_AI__TOOL__CALL__RESULT, + SEARCH_GEN_AI__TOOL__NAME, + SEARCH_GEN_AI__USAGE__INPUT_TOKENS, + SEARCH_GEN_AI__USAGE__OUTPUT_TOKENS, + SEARCH_ORIGIN, + SEARCH_SPAN__STATUS, +} from '@sentry/conventions/attributes/search'; +import { + GEN_AI_CHAT, + GEN_AI_EXECUTE_TOOL, + GEN_AI_INVOKE_AGENT, + HTTP_CLIENT, + HTTP_SERVER, +} from '@sentry/conventions/op'; +import type { TraceItem } from '@sentry-internal/test-utils/cli'; +import { EVENT_POLLING_OPTIONS, fetchSpanAttributes } from '@sentry-internal/test-utils/cli'; +import { newInstanceId, runAgentTurn, waitForConversationTrace } from './utils'; + +test('sends the request / invoke_agent / chat / execute_tool hierarchy of a Flue turn', async ({ baseURL }) => { + const conversationId = await runAgentTurn(baseURL!, newInstanceId('weather'), 'What is the weather in Paris?'); + + // Spans land at different times, so wait for the relationships checked below, not just the ops. + const isProviderCall = (span: TraceItem, spans: TraceItem[]) => + span.op === HTTP_CLIENT && spans.some(chat => chat.op === GEN_AI_CHAT && chat.event_id === span.parent_span_id); + const { traceId, spans } = await waitForConversationTrace( + conversationId, + spans => + [HTTP_SERVER, GEN_AI_INVOKE_AGENT, GEN_AI_EXECUTE_TOOL].every(op => spans.some(span => span.op === op)) && + spans.some(span => isProviderCall(span, spans)), + ); + + const request = spans.find(span => span.op === HTTP_SERVER); + const agents = spans.filter(span => span.op === GEN_AI_INVOKE_AGENT); + const invokeAgent = agents[0]; + const chats = spans.filter(span => span.op === GEN_AI_CHAT); + const executeTool = spans.find(span => span.description === 'execute_tool get_weather'); + + // More than one means other conversations leaked into this trace (withastro/flue#813). + expect(agents).toHaveLength(1); + expect(invokeAgent?.parent_span_id).toBe(request?.event_id); + // A chat span anywhere else would be a provider SDK reporting the same call a second time. + expect(chats.map(chat => chat.parent_span_id)).toEqual(chats.map(() => invokeAgent?.event_id)); + expect(executeTool?.parent_span_id).toBe(invokeAgent?.event_id); + + await expect + .poll(() => fetchSpanAttributes(traceId, invokeAgent!.event_id!), EVENT_POLLING_OPTIONS) + .toMatchObject({ + [SEARCH_ORIGIN]: 'auto.ai.flue', + [SEARCH_GEN_AI__OPERATION__NAME]: 'invoke_agent', + [SEARCH_GEN_AI__AGENT__NAME]: 'Hello', + [SEARCH_GEN_AI__CONVERSATION__ID]: conversationId, + }); + + let chatAttributes: Record | undefined; + await expect + .poll(() => (chatAttributes = fetchSpanAttributes(traceId, chats[0]!.event_id!)), EVENT_POLLING_OPTIONS) + .toMatchObject({ + [SEARCH_ORIGIN]: 'auto.ai.flue', + [SEARCH_SPAN__STATUS]: 'ok', + [SEARCH_GEN_AI__PROVIDER__NAME]: 'openrouter', + [SEARCH_GEN_AI__INPUT__MESSAGES]: expect.stringContaining('What is the weather in Paris?'), + }); + // The trace-items API returns integer attributes as strings. + expect(Number(chatAttributes![SEARCH_GEN_AI__USAGE__INPUT_TOKENS])).toBeGreaterThan(0); + expect(Number(chatAttributes![SEARCH_GEN_AI__USAGE__OUTPUT_TOKENS])).toBeGreaterThan(0); + // Flue computes cost itself; no provider SDK reports it. + expect(chatAttributes![SEARCH_GEN_AI__COST__TOTAL_TOKENS]).toBeGreaterThan(0); + + await expect + .poll(() => fetchSpanAttributes(traceId, executeTool!.event_id!), EVENT_POLLING_OPTIONS) + .toMatchObject({ + [SEARCH_ORIGIN]: 'auto.ai.flue', + [SEARCH_GEN_AI__TOOL__NAME]: 'get_weather', + // The model picks the argument (`Paris`, `Paris, France`, ...). + [SEARCH_GEN_AI__TOOL__CALL__ARGUMENTS]: expect.stringContaining('Paris'), + [SEARCH_GEN_AI__TOOL__CALL__RESULT]: expect.stringMatching(/^It is 21 degrees and sunny in Paris/), + }); +}); + +test('nests a manual span raised inside a tool under that tool span', async ({ baseURL }) => { + const conversationId = await runAgentTurn(baseURL!, newInstanceId('manual-span'), 'What is the weather in Berlin?'); + + const { traceId, spans } = await waitForConversationTrace(conversationId, spans => + spans.some( + span => span.description === 'resolve-weather' && spans.some(parent => parent.event_id === span.parent_span_id), + ), + ); + + const manualSpan = spans.find(span => span.description === 'resolve-weather'); + const parent = spans.find(span => span.event_id === manualSpan?.parent_span_id); + expect(parent?.description).toBe('execute_tool get_weather'); + + await expect + .poll(() => fetchSpanAttributes(traceId, manualSpan!.event_id!), EVENT_POLLING_OPTIONS) + .toMatchObject({ 'weather.source': 'static-table', 'weather.city': expect.stringContaining('Berlin') }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/tests/utils.ts b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/tests/utils.ts new file mode 100644 index 000000000000..7b3f2814266f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/tests/utils.ts @@ -0,0 +1,60 @@ +import { expect } from '@playwright/test'; +import { SEARCH_GEN_AI__CONVERSATION__ID } from '@sentry/conventions/attributes/search'; +import type { TraceItem } from '@sentry-internal/test-utils/cli'; +import { + EVENT_POLLING_OPTIONS, + fetchTrace, + findTraceIdOfSpan, + flattenTrace, + traceTarget, +} from '@sentry-internal/test-utils/cli'; + +/** A fresh instance id, so an earlier settlement cannot end the wait early. */ +export function newInstanceId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** Send one message and wait for the turn to settle. Returns the conversation id. */ +export async function runAgentTurn(baseURL: string, instanceId: string, message: string): Promise { + const url = `${baseURL}/agents/hello/${instanceId}`; + + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ kind: 'user', body: message }), + }); + const body = await res.text(); + expect(res.status, body).toBe(202); + + const deadline = Date.now() + 90_000; + while (Date.now() < deadline) { + const conversation = (await (await fetch(url)).json()) as { conversationId: string; settlements?: unknown[] }; + if (conversation.settlements?.length) { + return conversation.conversationId; + } + await new Promise(resolve => setTimeout(resolve, 1_000)); + } + + throw new Error(`Flue turn for "${instanceId}" did not settle within 90s`); +} + +/** The router answers `202` before the turn runs, so the trace is found by conversation id. */ +export async function waitForConversationTrace( + conversationId: string, + isComplete: (spans: TraceItem[]) => boolean, +): Promise<{ traceId: string; spans: TraceItem[] }> { + let traceId: string | undefined; + await expect + .poll( + () => (traceId = findTraceIdOfSpan(`${SEARCH_GEN_AI__CONVERSATION__ID}:${conversationId}`)), + EVENT_POLLING_OPTIONS, + ) + .toBeDefined(); + + console.log(`Polling for the agent spans: sentry trace view ${traceTarget(traceId!)}`); + + let spans: TraceItem[] = []; + await expect.poll(() => isComplete((spans = flattenTrace(fetchTrace(traceId!)))), EVENT_POLLING_OPTIONS).toBe(true); + + return { traceId: traceId!, spans }; +} diff --git a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/tsconfig.json new file mode 100644 index 000000000000..2cb98b13c9f8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "esnext", + "moduleResolution": "bundler", + "types": ["node"], + "strict": true, + "allowImportingTsExtensions": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src/**/*.ts", "sentry-init.ts", "flue.config.ts", "vite.config.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/vite.config.ts b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/vite.config.ts new file mode 100644 index 000000000000..a77574b64290 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/vite.config.ts @@ -0,0 +1,7 @@ +import { flue } from '@flue/vite'; +import { defineConfig } from 'vite'; + +// As `flue init` scaffolds it. Flue keeps dependencies as bare imports, so orchestrion can still hook them. +export default defineConfig({ + plugins: [flue()], +}); diff --git a/dev-packages/e2e-tests/test-applications/node-flue/package.json b/dev-packages/e2e-tests/test-applications/node-flue/package.json index 3c718a6a029f..eb7949b020d5 100644 --- a/dev-packages/e2e-tests/test-applications/node-flue/package.json +++ b/dev-packages/e2e-tests/test-applications/node-flue/package.json @@ -34,9 +34,6 @@ "engines": { "node": "24.x" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "optional": true, "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/node-flue/src/agents/hello.ts b/dev-packages/e2e-tests/test-applications/node-flue/src/agents/hello.ts index baf29050d836..8ed0c57e52eb 100644 --- a/dev-packages/e2e-tests/test-applications/node-flue/src/agents/hello.ts +++ b/dev-packages/e2e-tests/test-applications/node-flue/src/agents/hello.ts @@ -16,11 +16,11 @@ export function Hello() { input: v.object({ city: v.string() }), // Wrapped in a manual span: Flue runs the tool while the SDK's `execute_tool` span is active, // so this should nest directly under it rather than landing beside it. - run: ({ city }) => + run: ({ data }) => Sentry.startSpan( - { name: 'resolve-weather', attributes: { 'weather.source': 'static-table', 'weather.city': city } }, + { name: 'resolve-weather', attributes: { 'weather.source': 'static-table', 'weather.city': data.city } }, () => { - return `It is 21 degrees and sunny in ${city}.`; + return `It is 21 degrees and sunny in ${data.city}.`; }, ), }); diff --git a/dev-packages/e2e-tests/test-applications/node-flue/tests/flue.test.ts b/dev-packages/e2e-tests/test-applications/node-flue/tests/flue.test.ts index ccf3e552a87d..a829a0ba282e 100644 --- a/dev-packages/e2e-tests/test-applications/node-flue/tests/flue.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-flue/tests/flue.test.ts @@ -48,6 +48,31 @@ test('captures the invoke_agent / chat / execute_tool hierarchy for a Flue turn' expect(executeTool?.parent_span_id).toBe(invokeAgent?.span_id); }); +// Two turns: if the context leaked, the second would land in the first request's trace (withastro/flue#813). +test('puts each turn under the request that sent it', async ({ baseURL }) => { + for (const prefix of ['first-request', 'second-request']) { + const conversationId = newConversationId(prefix); + const isRequest = (span: SpanLike & { is_segment?: boolean }) => + span.is_segment === true && + span.name?.startsWith('POST') && + Object.values(span.attributes ?? {}).some( + attribute => typeof attribute?.value === 'string' && attribute.value.includes(conversationId), + ); + const spansPromise = collectStreamedSpans( + APP, + spansOfTrace => spansOfTrace.some(isRequest) && hasOps(['gen_ai.invoke_agent'])(spansOfTrace), + ); + + await runAgentTurn(baseURL!, conversationId, 'Say hello.'); + + const spans = await spansPromise; + const request = spans.find(isRequest); + const agents = spans.filter(span => getSpanOp(span) === 'gen_ai.invoke_agent'); + expect(agents).toHaveLength(1); + expect(agents[0]?.parent_span_id).toBe(request?.span_id); + } +}); + test('nests a manual span raised inside a tool under that tool span', async ({ baseURL }) => { const spansPromise = collectStreamedSpans( APP, @@ -61,6 +86,8 @@ test('nests a manual span raised inside a tool under that tool span', async ({ b const manualSpan = spans.find(span => span.name === 'resolve-weather'); expect(manualSpan?.attributes?.['weather.source']?.value).toBe('static-table'); + // The model chooses the argument (`Berlin`, `Berlin, Germany`, ...). + expect(manualSpan?.attributes?.['weather.city']?.value).toEqual(expect.stringContaining('Berlin')); expect(manualSpan?.trace_id).toBe(executeTool?.trace_id); expect(manualSpan?.parent_span_id).toBe(executeTool?.span_id); }); diff --git a/dev-packages/e2e-tests/test-applications/node-hapi/package.json b/dev-packages/e2e-tests/test-applications/node-hapi/package.json index 57fa79727712..2b0a3b370e22 100644 --- a/dev-packages/e2e-tests/test-applications/node-hapi/package.json +++ b/dev-packages/e2e-tests/test-applications/node-hapi/package.json @@ -17,9 +17,7 @@ }, "devDependencies": { "@playwright/test": "1.64.1", + "@sentry/conventions": "0.29.0", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/node-hapi/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/node-hapi/tests/spans.test.ts index 6f69c36ae622..55c563a68ad9 100644 --- a/dev-packages/e2e-tests/test-applications/node-hapi/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-hapi/tests/spans.test.ts @@ -1,4 +1,5 @@ import { expect, test } from '@playwright/test'; +import { HAPI_EXT_TYPE, HAPI_TYPE, SERVER_EXT_TYPE } from '@sentry/conventions/attributes'; import { waitForStreamedSpan, getSpanOp, collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; test('Sends successful span', async ({ baseURL }) => { @@ -97,10 +98,12 @@ test('Sends successful span', async ({ baseURL }) => { end_timestamp: expect.any(Number), trace_id: expect.stringMatching(/[a-f0-9]{32}/), attributes: expect.objectContaining({ - 'hapi.type': { value: 'server.ext', type: 'string' }, + [HAPI_TYPE]: { value: 'server.ext', type: 'string' }, + [HAPI_EXT_TYPE]: { value: 'onPreResponse', type: 'string' }, 'sentry.op': { value: 'middleware', type: 'string' }, 'sentry.origin': { value: 'auto.http.hapi', type: 'string' }, - 'server.ext.type': { value: 'onPreResponse', type: 'string' }, + // oxlint-disable-next-line typescript/no-deprecated + [SERVER_EXT_TYPE]: { value: 'onPreResponse', type: 'string' }, }), }), ]); diff --git a/dev-packages/e2e-tests/test-applications/node-koa/package.json b/dev-packages/e2e-tests/test-applications/node-koa/package.json index a3bbfc5359eb..ccb10ceee53c 100644 --- a/dev-packages/e2e-tests/test-applications/node-koa/package.json +++ b/dev-packages/e2e-tests/test-applications/node-koa/package.json @@ -20,8 +20,5 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/package.json b/dev-packages/e2e-tests/test-applications/node-mastra/package.json index 67ffbde6c0d5..e17539bcbe70 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/package.json +++ b/dev-packages/e2e-tests/test-applications/node-mastra/package.json @@ -9,7 +9,7 @@ "dev": "mastra dev --custom-args=\"--import=./instrument.mjs\"", "clean": "npx rimraf node_modules .mastra pnpm-lock.yaml", "test:build": "pnpm install && pnpm build", - "test:build-latest": "pnpm install && pnpm add @mastra/core@latest @mastra/observability@latest @mastra/memory@latest @mastra/libsql@latest mastra@latest && pnpm build", + "test:build-latest": "pnpm install && pnpm add @mastra/core@latest @mastra/deployer@latest @mastra/observability@latest @mastra/memory@latest @mastra/libsql@latest mastra@latest && pnpm build", "test:assert": "pnpm test:prod && pnpm test:dev", "test:prod": "TEST_ENV=production playwright test", "test:dev": "TEST_ENV=development playwright test" @@ -23,12 +23,13 @@ "@sentry/node": "file:../../packed/sentry-node-packed.tgz", "ai": "~7.0.97", "dataloader": "~2.2.3", - "zod": "4.5.4" + "zod": "4.6.6" }, "devDependencies": { + "@mastra/deployer": "~1.74.0", "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", - "@sentry/conventions": "0.27.0", + "@sentry/conventions": "0.29.0", "@types/node": "24.x", "mastra": "~1.28.0", "typescript": "~5.9.0" @@ -36,9 +37,6 @@ "engines": { "node": "24.x" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "optional": true, "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts b/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts index 7927a8f9531a..c12f9e9ed3e7 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts @@ -67,7 +67,7 @@ test('captures Mastra agent spans (invoke_agent, chat, execute_tool) with inputs ).toEqual([]); expect( traceSpans - .filter(span => String(attrValue(span, 'sentry.origin') ?? '').startsWith('auto.vercelai')) + .filter(span => String(attrValue(span, 'sentry.origin') ?? '').startsWith('auto.ai.vercelai')) .map(describeSpan), ).toEqual([]); diff --git a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/package.json index 8f6ee984c8a1..5f2855dd6dc1 100644 --- a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/package.json +++ b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/package.json @@ -24,7 +24,7 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", - "@sentry/conventions": "0.27.0", + "@sentry/conventions": "0.29.0", "@types/express": "^4.17.21", "@types/node": "^24.0.0", "sentry": "~0.44.1", @@ -38,9 +38,5 @@ "label": "node-openai-decisions-send-to-sentry (latest)" } ] - }, - "volta": { - "node": "24.15.0", - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/tests/send-to-sentry.test.ts b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/tests/send-to-sentry.test.ts index 7b0ae9c56e8f..f15dec0a9e8d 100644 --- a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/tests/send-to-sentry.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/tests/send-to-sentry.test.ts @@ -178,7 +178,7 @@ test('records a Vercel AI decision with the confidence of its answers', async ({ 'span.name': `decide ${MODEL}`, [SEARCH_SPAN__OP]: GEN_AI_DECIDE, [SEARCH_SPAN__STATUS]: 'ok', - [SEARCH_ORIGIN]: 'auto.vercelai.channel', + [SEARCH_ORIGIN]: 'auto.ai.vercelai', [SEARCH_GEN_AI__OPERATION__NAME]: 'decide', [SEARCH_GEN_AI__PROVIDER__NAME]: 'openai.decision', [SEARCH_GEN_AI__REQUEST__MODEL]: MODEL, diff --git a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/package.json index 886458da80ef..f94e4d8cf2ba 100644 --- a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/package.json +++ b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/package.json @@ -34,8 +34,5 @@ "label": "node-openai-send-to-sentry (latest)" } ] - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/package.json b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/package.json index 264efe90d301..906c97985e07 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/package.json +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/package.json @@ -21,8 +21,5 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json index ce771de4edba..809cb1a510fc 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json @@ -21,8 +21,5 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/node-pi-durable/package.json b/dev-packages/e2e-tests/test-applications/node-pi-durable/package.json index 7d6f0d26e879..423983c0ca7b 100644 --- a/dev-packages/e2e-tests/test-applications/node-pi-durable/package.json +++ b/dev-packages/e2e-tests/test-applications/node-pi-durable/package.json @@ -19,16 +19,13 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", - "@sentry/conventions": "0.27.0", + "@sentry/conventions": "0.29.0", "@sentry/core": "file:../../packed/sentry-core-packed.tgz", "@types/node": "24.x" }, "engines": { "node": "24.x" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "optional": true, "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/node-pi-durable/src/server.mjs b/dev-packages/e2e-tests/test-applications/node-pi-durable/src/server.mjs index c71c9f4096bf..093eaf74af5a 100644 --- a/dev-packages/e2e-tests/test-applications/node-pi-durable/src/server.mjs +++ b/dev-packages/e2e-tests/test-applications/node-pi-durable/src/server.mjs @@ -11,6 +11,7 @@ import { defineExtension, defineTool, Harness, + ProviderDoc, section, } from '@earendil-works/pi-durable'; import { openNodeSqliteStorage } from '@earendil-works/pi-durable/storage/sqlite/node'; @@ -125,7 +126,9 @@ async function handle(req, res) { { ownership: { kind: 'ownerless' }, agent: { model: MODEL } }, context, ); - return { id: conversation.id }; + // The SDK reports the provider session id as `gen_ai.conversation.id`. + const provider = await harness.snapshot(ProviderDoc, conversation.id, context); + return { id: conversation.id, sessionId: provider?.sessionId }; } // Returns as soon as the input is admitted. The run continues in the background, possibly in a diff --git a/dev-packages/e2e-tests/test-applications/node-pi-durable/tests/pi-durable.test.ts b/dev-packages/e2e-tests/test-applications/node-pi-durable/tests/pi-durable.test.ts index b00011096879..3c18f1bc8cc3 100644 --- a/dev-packages/e2e-tests/test-applications/node-pi-durable/tests/pi-durable.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-pi-durable/tests/pi-durable.test.ts @@ -21,15 +21,13 @@ import { createConversation, sendMessage } from './utils'; const APP = 'node-pi-durable'; test('traces a run as invoke_agent with chat, execute_tool and provider spans', async ({ baseURL }) => { - const conversationId = await createConversation(baseURL!); + const { id: conversationId, sessionId } = await createConversation(baseURL!); // A run is its own trace, and its `invoke_agent` segment ends last. const spansPromise = collectStreamedSpans( APP, spansOfTrace => - spansOfTrace.some( - span => - span.is_segment && String(span.attributes[GEN_AI_CONVERSATION_ID]?.value).endsWith(`:${conversationId}`), - ) && spansOfTrace.some(span => span.attributes[GEN_AI_TOOL_NAME]?.value === 'get_weather'), + spansOfTrace.some(span => span.is_segment && span.attributes[GEN_AI_CONVERSATION_ID]?.value === sessionId) && + spansOfTrace.some(span => span.attributes[GEN_AI_TOOL_NAME]?.value === 'get_weather'), ); const settled = await sendMessage( @@ -102,7 +100,7 @@ test('traces a run as invoke_agent with chat, execute_tool and provider spans', }); test('reports a throwing tool as an error on its execute_tool span', async ({ baseURL }) => { - const conversationId = await createConversation(baseURL!); + const { id: conversationId, sessionId } = await createConversation(baseURL!); const errorPromise = waitForError( APP, event => event.exception?.values?.[0]?.value === 'Intentional pi-durable tool failure', @@ -110,10 +108,8 @@ test('reports a throwing tool as an error on its execute_tool span', async ({ ba const spansPromise = collectStreamedSpans( APP, spansOfTrace => - spansOfTrace.some( - span => - span.is_segment && String(span.attributes[GEN_AI_CONVERSATION_ID]?.value).endsWith(`:${conversationId}`), - ) && spansOfTrace.some(span => span.attributes[GEN_AI_TOOL_NAME]?.value === 'fail_now'), + spansOfTrace.some(span => span.is_segment && span.attributes[GEN_AI_CONVERSATION_ID]?.value === sessionId) && + spansOfTrace.some(span => span.attributes[GEN_AI_TOOL_NAME]?.value === 'fail_now'), ); const settled = await sendMessage(baseURL!, conversationId, 'Call the fail_now tool, then tell me what happened.'); @@ -130,14 +126,12 @@ test('reports a throwing tool as an error on its execute_tool span', async ({ ba }); test('nests a subagent run under the tool call that delegated to it', async ({ baseURL }) => { - const conversationId = await createConversation(baseURL!); + const { id: conversationId, sessionId } = await createConversation(baseURL!); const spansPromise = collectStreamedSpans( APP, spansOfTrace => - spansOfTrace.some( - span => - span.is_segment && String(span.attributes[GEN_AI_CONVERSATION_ID]?.value).endsWith(`:${conversationId}`), - ) && spansOfTrace.some(span => !span.is_segment && getSpanOp(span) === GEN_AI_INVOKE_AGENT), + spansOfTrace.some(span => span.is_segment && span.attributes[GEN_AI_CONVERSATION_ID]?.value === sessionId) && + spansOfTrace.some(span => !span.is_segment && getSpanOp(span) === GEN_AI_INVOKE_AGENT), ); const settled = await sendMessage( @@ -157,24 +151,20 @@ test('nests a subagent run under the tool call that delegated to it', async ({ b expect(subagent.parent_span_id).toBe(delegate.span_id); expect(subagentChat).toBeDefined(); - // The subagent has a conversation of its own, in the same Harness. - const [agentHarness, agentConversation] = String(agent.attributes[GEN_AI_CONVERSATION_ID]?.value).split(':'); - const [subagentHarness, subagentConversation] = String(subagent.attributes[GEN_AI_CONVERSATION_ID]?.value).split(':'); - expect(subagentHarness).toBe(agentHarness); - expect(subagentConversation).not.toBe(agentConversation); + // The subagent has a conversation of its own. + expect(subagent.attributes[GEN_AI_CONVERSATION_ID]?.value).toEqual(expect.any(String)); + expect(subagent.attributes[GEN_AI_CONVERSATION_ID]?.value).not.toBe(sessionId); }); test('resumes a run in a new trace after the server crashes during a tool call', async ({ baseURL }) => { - const conversationId = await createConversation(baseURL!); + const { id: conversationId, sessionId } = await createConversation(baseURL!); // The tool span of the crashed process never ends, so a finished `crash_once` span can only come // from the rerun in the restarted server. const spansPromise = collectStreamedSpans( APP, spansOfTrace => - spansOfTrace.some( - span => - span.is_segment && String(span.attributes[GEN_AI_CONVERSATION_ID]?.value).endsWith(`:${conversationId}`), - ) && spansOfTrace.some(span => span.attributes[GEN_AI_TOOL_NAME]?.value === 'crash_once' && span.status === 'ok'), + spansOfTrace.some(span => span.is_segment && span.attributes[GEN_AI_CONVERSATION_ID]?.value === sessionId) && + spansOfTrace.some(span => span.attributes[GEN_AI_TOOL_NAME]?.value === 'crash_once' && span.status === 'ok'), ); const settled = await sendMessage( @@ -193,6 +183,8 @@ test('resumes a run in a new trace after the server crashes during a tool call', expect(getSpanOp(agent)).toBe(GEN_AI_INVOKE_AGENT); expect(tool.parent_span_id).toBe(agent.span_id); expect(answer?.parent_span_id).toBe(agent.span_id); + // The restarted process reads the conversation id from storage, also when the run resumes with a tool call. + expect(tool.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(sessionId); // The request that called the tool ran in the crashed process, so this trace starts with the rerun // of the tool. A run that never crashed would start with that request. expect(chats.every(chat => chat.start_timestamp >= tool.start_timestamp)).toBe(true); diff --git a/dev-packages/e2e-tests/test-applications/node-pi-durable/tests/utils.ts b/dev-packages/e2e-tests/test-applications/node-pi-durable/tests/utils.ts index 8f2754169431..eb908968ef90 100644 --- a/dev-packages/e2e-tests/test-applications/node-pi-durable/tests/utils.ts +++ b/dev-packages/e2e-tests/test-applications/node-pi-durable/tests/utils.ts @@ -1,9 +1,10 @@ import { expect } from '@playwright/test'; -export async function createConversation(baseURL: string): Promise { +/** Create a conversation. `sessionId` is the id the SDK reports as `gen_ai.conversation.id`. */ +export async function createConversation(baseURL: string): Promise<{ id: number; sessionId: string }> { const res = await fetch(`${baseURL}/conversations`, { method: 'POST' }); expect(res.status).toBe(200); - return ((await res.json()) as { id: number }).id; + return (await res.json()) as { id: number; sessionId: string }; } /** diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/package.json b/dev-packages/e2e-tests/test-applications/node-prisma-8/package.json index a9920f65d5a4..94c57f6271d9 100644 --- a/dev-packages/e2e-tests/test-applications/node-prisma-8/package.json +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/package.json @@ -23,8 +23,5 @@ "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "@sentry/core": "file:../../packed/sentry-core-packed.tgz" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/node-profiling-cjs/package.json b/dev-packages/e2e-tests/test-applications/node-profiling-cjs/package.json index 736a3a2f2317..4db8e675ec8e 100644 --- a/dev-packages/e2e-tests/test-applications/node-profiling-cjs/package.json +++ b/dev-packages/e2e-tests/test-applications/node-profiling-cjs/package.json @@ -17,8 +17,5 @@ "@types/node": "^18.19.1", "esbuild": "0.25.0", "typescript": "^5.7.3" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/node-profiling-electron/package.json b/dev-packages/e2e-tests/test-applications/node-profiling-electron/package.json index ba896ef7a85d..856d125aa141 100644 --- a/dev-packages/e2e-tests/test-applications/node-profiling-electron/package.json +++ b/dev-packages/e2e-tests/test-applications/node-profiling-electron/package.json @@ -15,9 +15,6 @@ "@sentry/profiling-node": "file:../../packed/sentry-profiling-node-packed.tgz", "electron": "^33.2.0" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "skip": true } diff --git a/dev-packages/e2e-tests/test-applications/node-profiling-esm/package.json b/dev-packages/e2e-tests/test-applications/node-profiling-esm/package.json index a2bd1e7119f6..3753211523c6 100644 --- a/dev-packages/e2e-tests/test-applications/node-profiling-esm/package.json +++ b/dev-packages/e2e-tests/test-applications/node-profiling-esm/package.json @@ -17,8 +17,5 @@ "@types/node": "^18.19.1", "esbuild": "0.28.1", "typescript": "^5.7.3" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/node-rolldown/package.json b/dev-packages/e2e-tests/test-applications/node-rolldown/package.json index 10d70f8cc7dd..cb74b43858f5 100644 --- a/dev-packages/e2e-tests/test-applications/node-rolldown/package.json +++ b/dev-packages/e2e-tests/test-applications/node-rolldown/package.json @@ -17,8 +17,5 @@ "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "graphql": "16.9.0", "rolldown": "1.2.14" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/node-rollup/package.json b/dev-packages/e2e-tests/test-applications/node-rollup/package.json index 8786bca27919..86267a84fa07 100644 --- a/dev-packages/e2e-tests/test-applications/node-rollup/package.json +++ b/dev-packages/e2e-tests/test-applications/node-rollup/package.json @@ -19,8 +19,5 @@ "rollup": "4.62.3", "@rollup/plugin-node-resolve": "^16.0.0", "@rollup/plugin-commonjs": "^28.0.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/node-vite/package.json b/dev-packages/e2e-tests/test-applications/node-vite/package.json index b1302aa7a7e1..48738a440a3f 100644 --- a/dev-packages/e2e-tests/test-applications/node-vite/package.json +++ b/dev-packages/e2e-tests/test-applications/node-vite/package.json @@ -17,8 +17,5 @@ "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "graphql": "16.9.0", "vite": "6.4.3" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/node-webpack/package.json b/dev-packages/e2e-tests/test-applications/node-webpack/package.json index c4164c8fd4a1..1efc6ec0ff88 100644 --- a/dev-packages/e2e-tests/test-applications/node-webpack/package.json +++ b/dev-packages/e2e-tests/test-applications/node-webpack/package.json @@ -17,8 +17,5 @@ "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "graphql": "16.9.0", "webpack": "5.107.2" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/package.json b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/package.json index 5c53691ac811..06e4f650f55d 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/package.json @@ -20,8 +20,5 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json b/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json index b5299208a860..a1fe29e4acc4 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json @@ -25,7 +25,7 @@ "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" }, - "volta": { - "extends": "../../package.json" + "sentryTest": { + "runner": "namespace-profile-sentry-javascript" } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/package.json b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/package.json index c872451dce47..7f4660ca2f3c 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/package.json @@ -21,8 +21,5 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/package.json b/dev-packages/e2e-tests/test-applications/nuxt-3/package.json index 7fcb1fbf4080..d1891d751fc7 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/package.json @@ -31,8 +31,5 @@ "label": "nuxt-3 (canary)" } ] - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts index 63f6e2e2633b..b83483ac0363 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts @@ -2,6 +2,8 @@ export default defineNuxtConfig({ compatibilityDate: '2025-06-06', modules: ['@sentry/nuxt/module'], + // The tunnel variant serves the app, and so the tunnel, under a base URL. + ...(process.env.E2E_TEST_TUNNEL && { app: { baseURL: '/app/' } }), nitro: { preset: 'cloudflare_module', cloudflare: { @@ -14,5 +16,9 @@ export default defineNuxtConfig({ alias: { 'readable-stream': 'node:stream', }, + // The tunnel variant serves the managed tunnel. + replace: { + __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + }, }, }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json index e58f8c4efe55..7fb1258723d6 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json @@ -8,7 +8,9 @@ "preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --port 3030 --log-level=$(test $CI && echo 'none' || echo 'log')", "test": "playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test" + "test:assert": "pnpm test", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 pnpm test tunnel" }, "dependencies": { "@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz", @@ -21,7 +23,13 @@ "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "wrangler": "^4.72.0" }, - "volta": { - "extends": "../../package.json" + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "nuxt-4-cloudflare (tunnel)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/anchor.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/anchor.ts new file mode 100644 index 000000000000..cee7601e363b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/anchor.ts @@ -0,0 +1,4 @@ +import { defineEventHandler } from '#imports'; + +// A plain route for tests to request after the tunnel traffic. +export default defineEventHandler(() => 'ok'); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts index faa56e1db7ef..e4cc28eaf3ae 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts @@ -1,10 +1,15 @@ import { defineNitroPlugin } from '#imports'; +import { serveTunnelIntegration } from '@sentry/cloudflare'; import { sentryCloudflareNitroPlugin } from '@sentry/nuxt/module/plugins'; +declare const __E2E_TEST_TUNNEL__: boolean; + export default defineNitroPlugin( sentryCloudflareNitroPlugin({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', + // The tunnel forwards to the DSN host, which is the event proxy in the tunnel variant. + dsn: __E2E_TEST_TUNNEL__ ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1.0, tunnel: 'http://localhost:3031/', // proxy server + integrations: __E2E_TEST_TUNNEL__ ? [serveTunnelIntegration()] : [], }), ); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts new file mode 100644 index 000000000000..33c00290d888 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from '@playwright/test'; +import { isServerSpanAt, makeClientReportEnvelope, watchForTunnelSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +// The isolate's first tunnel request is the one a late span filter would let through, so this stays the only test. +test('answers the managed tunnel set up by `sentryCloudflareNitroPlugin`, without a span of it', async ({ + request, +}) => { + const dsn = 'http://public@localhost:3031/1337'; + const raceTunnelSpan = watchForTunnelSpan('nuxt-4-cloudflare', isServerSpanAt(/^\/app\/qpo967/)); + + // The path the SDK derives from `dsn`. + const response = await request.post('/app/qpo967', { + data: makeClientReportEnvelope(dsn), + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }); + + expect(response.status()).toBe(200); + expect(await raceTunnelSpan(isServerSpanAt('/app/api/anchor'), () => request.get('/app/api/anchor'))).toBe('anchor'); +}); 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 8a9ee339b1e6..782b54789526 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 @@ -2,19 +2,15 @@ import * as assert from 'assert/strict'; import * as fs from 'fs'; import * as path from 'path'; import { + assertSourceMapUpload, findDebugIdsWithConflictingSourcemaps, findFilesWithForeignSourcemaps, findFilesWithMultipleDebugIds, findInjectedDebugIds, findSourceMapFiles, findSourceMappingUrlComments, - getArtifactBundles, getAssembleRequests, - getChunkUploadPosts, - getDebugIdPairs, getSourcemapSources, - getSourcemaps, - loadMockServerResults, startNodeServer, } from '@sentry-internal/test-utils'; @@ -86,20 +82,22 @@ console.log( `client source maps ${keepClientSourceMaps ? 'kept' : 'deleted'}\n`, ); -const requests = loadMockServerResults(); - -console.log(`Captured ${requests.length} requests to mock Sentry server:\n`); -for (const request of requests) { - console.log(` ${request.method} ${request.url} (${request.bodySize} bytes)`); -} -console.log(''); - -// --- The upload reached Sentry --- +const { requests, bundles, sourcemaps, uploadedDebugIds } = assertSourceMapUpload({ + outputDirs: isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER_OUTPUT], + // The client output is checked below, because the "kept" variant expects its maps to stay and some presets + // write the server into it. + strippedDirs: [], + // Vite builds the client and Nitro's Rollup builds the server. Counting bundles would still pass + // with either plugin dropped, so each side is pinned to a marker only that side's source supplies. + // Nitro defaults to `sourcemapExcludeSources: true`; the module flips it to `false`, which is the + // only reason the server marker survives into `sourcesContent`. + markers: { 'client source (Vite plugin)': CLIENT_MARKER, 'server source (Rollup plugin)': SERVER_MARKER }, + // Nitro copies Vite's client output to `CLIENT_OUTPUT`, and Sentry deletes no maps in Vite's own directory. + debugIdsWithoutSourceMap: debugIdsOfChunksWithoutSourceMap(CLIENT_BUILD_OUTPUT), +}); +console.log(`Found ${bundles.length} artifact bundle(s)\n`); -assert.ok( - requests.some(r => r.authorization.includes('fake-auth-token')), - 'Expected requests with the configured auth token', -); +// --- Nuxt-specific checks on the upload --- // Sentry rejects a release that names no project with a 400, and the mock server accepts anything, // so assert on the body rather than on the call alone. @@ -129,79 +127,23 @@ assert.ok( 'Expected a GET for the chunk-upload options', ); -const chunkPosts = getChunkUploadPosts(requests); -assert.ok( - chunkPosts.some(r => r.bodySize > 0), - 'Expected at least one chunk upload POST with a non-empty body', -); - -const assembleRequests = getAssembleRequests(requests); -assert.ok(assembleRequests.length > 0, 'Expected at least one assemble request'); -for (const request of assembleRequests) { - assert.ok(request.assembleBody?.projects?.includes('test-project'), 'Expected assemble request for test-project'); - assert.equal(request.assembleBody?.version, 'test-release', 'Expected assemble request to reference the release'); +for (const request of getAssembleRequests(requests)) { assert.ok((request.assembleBody?.chunks?.length ?? 0) > 0, 'Expected assemble request to have chunk checksums'); for (const chunk of request.assembleBody?.chunks ?? []) { assert.match(chunk, /^[\da-f]{40}$/i, `Expected a SHA-1 chunk checksum, got: ${chunk}`); } } -const bundles = getArtifactBundles(requests); -assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest'); -console.log(`Found ${bundles.length} artifact bundle(s)\n`); - -// --- Both bundlers uploaded --- - -const sourcemaps = getSourcemaps(bundles); -assert.ok( - sourcemaps.some(map => map.sourcemap.mappings?.length), - 'Expected at least one uploaded sourcemap with non-empty mappings', -); - -const containsMarker = (marker: string): boolean => - sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))); - -// Vite builds the client and Nitro's Rollup builds the server. Counting bundles would still pass -// with either plugin dropped, so each side is pinned to a marker only that side's source supplies. -assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded sourcemap carrying the client source (Vite plugin)'); -// Nitro defaults to `sourcemapExcludeSources: true`; the module flips it to `false`, which is the -// only reason this marker survives into `sourcesContent`. -assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded sourcemap carrying the server source (Rollup plugin)'); - // `rewriteSources` normalizes `../../../foo` to `./foo` so paths stay resolvable in Sentry. const unnormalizedSources = [...new Set(sourcemaps.flatMap(map => map.sourcemap.sources ?? []))].filter( source => source.startsWith('../') || path.isAbsolute(source), ); assert.deepEqual(unnormalizedSources, [], `Expected every uploaded source to be normalized to './…'`); -// --- Debug IDs tie the shipped bundle to the uploaded map --- - -const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase())); -assert.ok(uploadedDebugIds.size > 0, 'Expected at least one JS/sourcemap pair with matching debug IDs'); - const malformedDebugIds = [...uploadedDebugIds].filter(debugId => !UUID_REGEX.test(debugId)); assert.deepEqual(malformedDebugIds, [], 'Expected every uploaded debug ID to be a UUID'); -// An uploaded map is only reachable at runtime if the shipped bundle claims the same ID. Inspecting -// the upload alone cannot show this. -const debugIdsWithoutSourceMap = debugIdsOfChunksWithoutSourceMap(CLIENT_BUILD_OUTPUT); - for (const outputDir of isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER_OUTPUT]) { - const injectedDebugIds = findInjectedDebugIds({ outputDir }); - assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); - // The exemption for chunks without a source map must not let a build without any uploaded map pass. - assert.ok( - injectedDebugIds.some(debugId => uploadedDebugIds.has(debugId)), - `Expected at least one debug ID in ${outputDir} to have an uploaded sourcemap`, - ); - - const unuploaded = injectedDebugIds.filter( - debugId => !uploadedDebugIds.has(debugId) && !debugIdsWithoutSourceMap.has(debugId), - ); - assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded sourcemap`); - - console.log(` ${outputDir}: ${injectedDebugIds.length} injected debug ID(s), all uploaded`); - // Nitro bundles the SSR build again, so its debug IDs can end up next to Nitro's own. // Reference issue: https://github.com/getsentry/sentry-javascript/issues/18519 const filesWithMultipleDebugIds = findFilesWithMultipleDebugIds({ outputDir }); 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 fed9ff583515..89a72de550af 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 @@ -28,9 +28,6 @@ "ts-node": "10.9.1", "typescript": "~5.0.0" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "variants": [ { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4-static/package.json index ee355572b388..e21627e76f08 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-static/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/package.json @@ -26,13 +26,11 @@ }, "devDependencies": { "@playwright/test": "1.64.1", - "@sentry/conventions": "0.27.0", + "@sentry/conventions": "0.29.0", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { + "runner": "namespace-profile-sentry-javascript", "variants": [ { "build-command": "E2E_TEST_OTEL_SETUP=true pnpm test:build", diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/db-ssr.vue b/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/db-ssr.vue new file mode 100644 index 000000000000..de0125832e06 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/db-ssr.vue @@ -0,0 +1,33 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts index c7acd2b12328..272aaf5061c7 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts @@ -1,7 +1,10 @@ // https://nuxt.com/docs/api/configuration/nuxt-config +const tunnelVariant = !!process.env.E2E_TEST_TUNNEL; + export default defineNuxtConfig({ compatibilityDate: '2025-06-06', imports: { autoImport: false }, + ...(process.env.E2E_TEST_VITE_SERVER && { server: { builder: 'vite' } }), routeRules: { '/rendering-modes/client-side-only-page': { ssr: false }, @@ -10,14 +13,21 @@ export default defineNuxtConfig({ '/rendering-modes/swr-cached-page': { swr: true }, '/rendering-modes/swr-1h-cached-page': { swr: 3600 }, '/rendering-modes/pre-rendered-page': { prerender: true }, + // A headers rule on every path, like a security headers setup, also covers the tunnel's path. + ...(tunnelVariant && { '/**': { headers: { 'x-e2e-route-rule': '1' } } }), }, + // The base-path variant serves the app, and so the tunnel, under `/app`. + ...(process.env.E2E_TEST_BASE_PATH && { app: { baseURL: `${process.env.E2E_TEST_BASE_PATH}/` } }), + modules: ['@pinia/nuxt', '@sentry/nuxt/module'], runtimeConfig: { public: { sentry: { - dsn: 'https://public@dsn.ingest.sentry.io/1337', + // The tunnel variant forwards to the test proxy, so the DSN has to point there. + dsn: tunnelVariant ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', }, + e2eTunnel: tunnelVariant, }, }, nitro: { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index 92fa2a41d87b..abfe18e43daa 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -16,7 +16,10 @@ "test:build": "pnpm install && node ./scripts/build-with-prerender-event-sink.mjs", "test:build-canary": "pnpm add nuxt@npm:nuxt-nightly@latest && pnpm add nitropack@npm:nitropack-nightly@latest && pnpm install --force && node ./scripts/build-with-prerender-event-sink.mjs", "test:assert": "pnpm test:prod && pnpm test:dev", - "test:prod:import": "TEST_ENV=production-import playwright test" + "test:prod:import": "TEST_ENV=production-import playwright test", + "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app node ./scripts/build-with-prerender-event-sink.mjs", + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-basepath-dev": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app TEST_ENV=development playwright test tunnel" }, "//": "Pin ioredis to 5.10.1 because that's the last version before it publishes its own diagnostics channels", "dependencies": { @@ -31,10 +34,8 @@ "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { + "runner": "namespace-profile-sentry-javascript", "variants": [ { "build-command": "E2E_TEST_OTEL_SETUP=true pnpm test:build", @@ -45,6 +46,21 @@ "build-command": "pnpm test:build", "assert-command": "pnpm test:prod:import", "label": "nuxt-4 (--import compat)" + }, + { + "build-command": "E2E_TEST_VITE_SERVER=true pnpm test:build", + "assert-command": "E2E_TEST_VITE_SERVER=true pnpm test:prod", + "label": "nuxt-4 (Vite server)" + }, + { + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath", + "label": "nuxt-4 (tunnel-basepath)" + }, + { + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-basepath-dev", + "label": "nuxt-4 (tunnel-basepath-dev)" } ], "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts index 7b5d97ff0b09..64c786d5f7e8 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts @@ -3,7 +3,7 @@ import { usePinia, useRuntimeConfig } from '#imports'; Sentry.init({ dsn: useRuntimeConfig().public.sentry.dsn, - tunnel: `http://localhost:3031/`, // proxy server + tunnel: useRuntimeConfig().public.e2eTunnel ? true : `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, integrations: [ Sentry.piniaIntegration(usePinia(), { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts index acce04cc9d04..c956224de1fc 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts @@ -1,12 +1,15 @@ import * as Sentry from '@sentry/nuxt'; Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: process.env.E2E_TEST_TUNNEL ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1.0, // Capture 100% of the transactions tunnel: 'http://localhost:3031/', // proxy server // Opt into the Sentry OpenTelemetry tracer provider in the "(tracer provider)" e2e variant. // Leaving it `undefined` otherwise keeps the SDK's default (no provider). enableOpenTelemetrySetup: process.env.E2E_TEST_OTEL_SETUP === 'true' ? true : undefined, // Several tests trigger the same server error one after another, which Dedupe would drop. - integrations: integrations => integrations.filter(integration => integration.name !== 'Dedupe'), + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'Dedupe'), + ...(process.env.E2E_TEST_TUNNEL ? [Sentry.serveTunnelIntegration()] : []), + ], }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts index 6dcd9a075589..471d1d32d7a4 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts @@ -1,6 +1,11 @@ -import { defineEventHandler, setHeader, getQuery } from '#imports'; +import { createError, defineEventHandler, getHeader, setHeader, getQuery } from '#imports'; export default defineEventHandler(async event => { + // The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. + if (process.env.E2E_TEST_TUNNEL && event.method === 'POST' && getHeader(event, 'x-auth') !== 'ok') { + throw createError({ statusCode: 401, statusMessage: 'blocked by user auth' }); + } + // Check if we should throw an error const query = getQuery(event); if (query.throwError === 'true') { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/build-injection.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/build-injection.test.ts index cd609b2321a4..e00258723c71 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/build-injection.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/build-injection.test.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import path from 'node:path'; import { expect, test } from '@playwright/test'; +import { isViteServer, NITRO_ONLY, NO_IMPORT_CONFIG } from './isViteServer'; function readServerBundle(): string { const serverDir = path.join(process.cwd(), '.output/server'); @@ -34,6 +35,12 @@ test.describe('Orchestrion build-time injection', () => { // on the global marker (and registers its subscriber factory) when evaluated. expect(serverBundle).toContain('orchestrionModuleInjected'); expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:mysql:query["']\)/); + }); + + test('injects diagnostics-channel publishers into ioredis', () => { + // Only a server route imports ioredis. + test.skip(isViteServer, NITRO_ONLY); + expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:command["']\)/); expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:connect["']\)/); }); @@ -46,6 +53,8 @@ test.describe('Orchestrion build-time injection', () => { test.describe('Sentry server config injection', () => { test('evaluates Sentry.init before nitro runs its plugins', () => { + test.skip(isViteServer, NITRO_ONLY); + const nitroChunk = readFileSync(path.join(process.cwd(), '.output/server/chunks/nitro/nitro.mjs'), 'utf8'); // The app DSN only appears in the transpiled `Sentry.init` options object, so it marks where @@ -59,6 +68,8 @@ test.describe('Sentry server config injection', () => { }); test('emits the `--import` compatibility shim at the former config path', () => { + test.skip(isViteServer, NO_IMPORT_CONFIG); + const shimPath = path.join(process.cwd(), '.output/server/sentry.server.config.mjs'); expect(existsSync(shimPath)).toBe(true); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts index 0917da52d140..812ae2641ca0 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts @@ -1,5 +1,8 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; +import { isViteServer, NITRO_ONLY } from './isViteServer'; + +test.skip(isViteServer, NITRO_ONLY); test.describe('Cache Instrumentation', () => { const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts index bec2b7c3b269..50a0f6ae09d6 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts @@ -1,5 +1,8 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment, getSpanOp } from '@sentry-internal/test-utils'; +import { isViteServer, NITRO_ONLY } from './isViteServer'; + +test.skip(isViteServer, NITRO_ONLY); async function collectDbSpans() { const spans = await collectStreamedSpansUntilSegment( diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts index b73c4ee990f4..ab6071727d6f 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts @@ -1,5 +1,8 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment, getSpanOp, waitForError } from '@sentry-internal/test-utils'; +import { isViteServer, NITRO_ONLY } from './isViteServer'; + +test.skip(isViteServer, NITRO_ONLY); async function collectDbSpans() { const spans = await collectStreamedSpansUntilSegment( diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.ssr.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.ssr.test.ts new file mode 100644 index 000000000000..dd0aba69fe24 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.ssr.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; + +test('Instruments mysql in a page that queries the database while it renders', async ({ baseURL }) => { + const spansPromise = collectStreamedSpansUntilSegment( + 'nuxt-4', + span => span.attributes['url.path']?.value === '/db-ssr', + ); + + const response = await fetch(`${baseURL}/db-ssr`); + expect(response.status).toBe(200); + expect(await response.text()).toContain('

2

'); + + const spans = await spansPromise; + const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === '/db-ssr'); + const mysqlSpans = spans.filter( + span => span.trace_id === rootSpan?.trace_id && span.attributes['sentry.origin']?.value === 'auto.db.mysql', + ); + + expect(mysqlSpans).toHaveLength(1); + expect(mysqlSpans[0]).toEqual( + expect.objectContaining({ + name: 'SELECT', + status: 'ok', + is_segment: false, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'db' }, + 'db.system.name': { type: 'string', value: 'mysql' }, + 'db.query.text': { type: 'string', value: 'SELECT ? + ? AS solution' }, + }), + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts index 38000686a79b..4702225dda1b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts @@ -1,5 +1,8 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment, getSpanOp } from '@sentry-internal/test-utils'; +import { isViteServer, NITRO_ONLY } from './isViteServer'; + +test.skip(isViteServer, NITRO_ONLY); // The Nuxt module auto-wires the orchestrion build-time transform, which injects // `diagnostics_channel` publishers into these drivers as Nitro bundles them. `nuxt dev` diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/environment.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/environment.test.ts index 35e626110382..005e694e0b96 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/environment.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/environment.test.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { isDevMode } from './isDevMode'; +import { isViteServer, NITRO_ONLY } from './isViteServer'; test.describe('environment detection', async () => { test('sets correct environment for client-side errors', async ({ page }) => { @@ -38,6 +39,8 @@ test.describe('environment detection', async () => { }); test('sets correct environment for server-side errors', async ({ page }) => { + test.skip(isViteServer, NITRO_ONLY); + const errorPromise = waitForError('nuxt-4', async errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 4 Server error'; }); 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 928434c44b27..b9c8f5f8c5d1 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,8 +1,11 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; import { IMPORT_SURFACES } from './importSurfaces'; +import { isViteServer, NITRO_ONLY } from './isViteServer'; import { waitForHydration } from './waitForHydration'; +test.skip(isViteServer, NITRO_ONLY); + IMPORT_SURFACES.forEach(({ name, apiPrefix }) => { test.describe(`server-side errors (${name})`, () => { test('captures api fetch error (fetched on click)', async ({ page }) => { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/import-compat.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/import-compat.test.ts index a2eac8bbbf54..be1316fb61a2 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/import-compat.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/import-compat.test.ts @@ -2,6 +2,7 @@ import type { ChildProcess } from 'node:child_process'; import { spawn } from 'node:child_process'; import { expect, test } from '@playwright/test'; import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { isViteServer, NO_IMPORT_CONFIG } from './isViteServer'; // `node --import` start commands must keep working now that the config is bundled: the emitted // config file is a shim that only prints a removal hint, and a preload that really initializes @@ -34,6 +35,8 @@ async function startServerWithPreload(preloadPath: string, port: string): Promis } test('serves traced requests with the shim preloaded and prints the removal hint', async () => { + test.skip(isViteServer, NO_IMPORT_CONFIG); + const server = await startServerWithPreload('./.output/server/sentry.server.config.mjs', '3081'); try { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/isViteServer.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/isViteServer.ts new file mode 100644 index 000000000000..d0c9404a88e9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/isViteServer.ts @@ -0,0 +1,7 @@ +export const isViteServer = !!process.env.E2E_TEST_VITE_SERVER; + +export const NITRO_ONLY = 'Nitro only: the Vite server builds no `server/` directory'; + +export const NO_IMPORT_CONFIG = 'Nitro only: the Vite server never had an `--import` config to keep working'; + +export const NO_META_TAGS = 'Vite server: the SDK adds no trace meta tags yet'; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/middleware.test.ts index cc1dd5b14d2d..cc12ce192ebd 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/middleware.test.ts @@ -1,6 +1,9 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment, getSpanOp, waitForError } from '@sentry-internal/test-utils'; import { IMPORT_SURFACES } from './importSurfaces'; +import { isViteServer, NITRO_ONLY } from './isViteServer'; + +test.skip(isViteServer, NITRO_ONLY); // Nitro runs every file in `server/middleware/` on every request, so each surface tells its spans apart by // middleware name. `nuxt/server` has no object syntax (`onRequest`, `onBeforeResponse`) and no `eventHandler` diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/startup-error.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/startup-error.test.ts index fd6a98a0f714..b51a0719c206 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/startup-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/startup-error.test.ts @@ -1,6 +1,9 @@ import { spawn } from 'node:child_process'; import { expect, test } from '@playwright/test'; import { waitForError, waitForSession } from '@sentry-internal/test-utils'; +import { isViteServer, NITRO_ONLY } from './isViteServer'; + +test.skip(isViteServer, NITRO_ONLY); // Errors thrown between server start and `listen` (module evaluation, nitro plugin runs) must be // captured and flushed before the process exits. Each test spawns its own server process because diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts index 8482fea9fe79..9152c4d82905 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts @@ -1,5 +1,8 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; +import { isViteServer, NITRO_ONLY } from './isViteServer'; + +test.skip(isViteServer, NITRO_ONLY); async function collectStorageSpans(route: string) { const spans = await collectStreamedSpansUntilSegment('nuxt-4', span => span.attributes['url.path']?.value === route); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage.test.ts index ecee3f1146db..1c4ea12af2db 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage.test.ts @@ -1,5 +1,8 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; +import { isViteServer, NITRO_ONLY } from './isViteServer'; + +test.skip(isViteServer, NITRO_ONLY); async function collectStorageSpans(route: string) { const spans = await collectStreamedSpansUntilSegment('nuxt-4', span => span.attributes['url.path']?.value === route); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.cached-html.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.cached-html.test.ts index 45afb6475b20..42ed5115020c 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.cached-html.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.cached-html.test.ts @@ -1,16 +1,20 @@ import { expect, test, type Page } from '@playwright/test'; import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { isViteServer, NO_META_TAGS } from './isViteServer'; test.describe('Rendering Modes with Cached HTML', () => { test('changes tracing meta tags with multiple requests on Client-Side only page', async ({ page }) => { + test.fail(isViteServer, NO_META_TAGS); await testChangingTracingMetaTagsOnISRPage(page, '/rendering-modes/client-side-only-page', 'Client Side Only Page'); }); test('changes tracing meta tags with multiple requests on ISR-cached page', async ({ page }) => { + test.fail(isViteServer, NO_META_TAGS); await testChangingTracingMetaTagsOnISRPage(page, '/rendering-modes/isr-cached-page', 'ISR Cached Page'); }); test('changes tracing meta tags with multiple requests on 1h ISR-cached page', async ({ page }) => { + test.fail(isViteServer, NO_META_TAGS); await testChangingTracingMetaTagsOnISRPage(page, '/rendering-modes/isr-1h-cached-page', 'ISR 1h Cached Page'); }); @@ -69,6 +73,7 @@ export async function testChangingTracingMetaTagsOnISRPage( expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), ]); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(1); const baggageMetaTagContent1 = await page.locator('meta[name="baggage"]').getAttribute('content'); const sentryTraceMetaTagContent1 = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); const [htmlMetaTraceId1] = sentryTraceMetaTagContent1?.split('-') || []; @@ -85,6 +90,7 @@ export async function testChangingTracingMetaTagsOnISRPage( expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), ]); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(1); const baggageMetaTagContent2 = await page.locator('meta[name="baggage"]').getAttribute('content'); const sentryTraceMetaTagContent2 = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); const [htmlMetaTraceId2] = sentryTraceMetaTagContent2?.split('-') || []; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts index 4ef6d7c039e0..a0c8630bd730 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { IMPORT_SURFACES } from './importSurfaces'; +import { isViteServer, NITRO_ONLY } from './isViteServer'; test('sends a server root span on pageload', async ({ page }) => { const serverSpanPromise = waitForStreamedSpan('nuxt-4', span => { @@ -41,6 +42,8 @@ test('does not send spans for build asset folder "_nuxt"', async ({ page }) => { IMPORT_SURFACES.forEach(({ name, apiPrefix }) => { test(`captures server API calls made with Nitro $fetch (${name})`, async ({ page }) => { + test.skip(isViteServer, NITRO_ONLY); + // The server-side http.client span can flush before its segment. Accumulate until both arrived. const spansPromise = collectStreamedSpans('nuxt-4', spans => { return ( diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts index 7ee91e3eeb9c..b67402a6ae23 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts @@ -1,11 +1,14 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { IMPORT_SURFACES } from './importSurfaces'; +import { isViteServer, NITRO_ONLY, NO_META_TAGS } from './isViteServer'; test.describe('distributed tracing', () => { const PARAM = 's0me-param'; test('capture a distributed pageload trace', async ({ page }) => { + test.fail(isViteServer, NO_META_TAGS); + const clientSpanPromise = waitForStreamedSpan('nuxt-4', span => { return getSpanOp(span) === 'pageload' && span.is_segment; }); @@ -21,6 +24,7 @@ test.describe('distributed tracing', () => { expect(page.getByText(`Param: ${PARAM}`)).toBeVisible(), ]); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(1); const baggageMetaTagContent = await page.locator('meta[name="baggage"]').getAttribute('content'); // URL-encoded for parametrized 'GET /test-param/s0me-param' -> `GET /test-param/:param` @@ -66,6 +70,8 @@ test.describe('distributed tracing', () => { test(`capture a distributed trace from a client-side API request with parametrized routes (${name})`, async ({ page, }) => { + test.skip(isViteServer, NITRO_ONLY); + const API_PATH = `${apiPrefix}/user/${PARAM}`; // The `http.client` span ends after the pageload segment, so it can be flushed in a later diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel.test.ts new file mode 100644 index 000000000000..cad4d5db942c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from '@playwright/test'; +import { + isServerSpanAt, + makeClientReportEnvelope, + waitForError, + watchForTunnelSpan, +} from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +test.beforeAll(async ({ request }) => { + // `nuxt dev` opens the port before it's ready and answers 503 until then, and Playwright only waits for the port. + await expect + .poll(async () => (await request.get(`${process.env.E2E_TEST_BASE_PATH ?? ''}/`)).status(), { timeout: 30_000 }) + .not.toBe(503); +}); + +const BASE_PATH = process.env.E2E_TEST_BASE_PATH ?? ''; +const TUNNEL_PATH = new RegExp(`^${BASE_PATH}/[a-z0-9]{1,7}$`); + +test('answers the managed tunnel before the app auth middleware', async ({ page }) => { + // The variant's auth middleware rejects every other POST. + expect((await page.request.post(`${BASE_PATH}/api/anything`, { data: 'x' })).status()).toBe(401); + + const raceTunnelSpan = watchForTunnelSpan('nuxt-4', isServerSpanAt(TUNNEL_PATH)); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto(`${BASE_PATH}/`); + + expect((await tunnelResponsePromise).status()).toBe(200); + expect( + await raceTunnelSpan(isServerSpanAt(`${BASE_PATH}/api/test-param/123`), () => + page.request.get(`${BASE_PATH}/api/test-param/123`), + ), + ).toBe('anchor'); +}); + +test('answers the managed tunnel once, without errors from route rules that run after it', async ({ request }) => { + const dsn = 'http://public@localhost:3031/1337'; + // The path the SDK derives from `dsn`. + const tunnelPath = '/qpo967'; + const tunnelErrorPromise = waitForError( + 'nuxt-4', + event => !!event.exception?.values?.[0]?.value?.includes('Cannot set headers'), + ); + const anchorErrorPromise = waitForError( + 'nuxt-4', + event => event.exception?.values?.[0]?.value === 'Nuxt 4 Server error', + ); + + const response = await request.post(`${BASE_PATH}${tunnelPath}`, { + data: makeClientReportEnvelope(dsn), + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }); + expect(response.status()).toBe(200); + + // Errors are sent in order, so one from the tunnel request would arrive before this later one. + await request.get(`${BASE_PATH}/api/server-error`); + + const winner = await Promise.race([ + tunnelErrorPromise.then(() => 'tunnel' as const), + anchorErrorPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts index 0a86f401f470..8c64d688098f 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts @@ -2,18 +2,15 @@ import * as assert from 'assert/strict'; import * as fs from 'fs'; import * as path from 'path'; import { + assertSourceMapUpload, + findDebugIdsInFilesWithGlobCharacters, findDebugIdsWithConflictingSourcemaps, findFilesWithForeignSourcemaps, findFilesWithMultipleDebugIds, findInjectedDebugIds, findSourceMapFiles, findSourceMappingUrlComments, - getArtifactBundles, getAssembleRequests, - getChunkUploadPosts, - getDebugIdPairs, - getSourcemaps, - loadMockServerResults, startNodeServer, } from '@sentry-internal/test-utils'; @@ -61,43 +58,39 @@ function debugIdsOfChunksWithoutSourceMap(dir: string): Set { return debugIds; } -// TODO: The uploader passes the emitted file paths to `glob` as patterns, so it skips a file whose path has glob -// characters, like Nitro 3's `_libs/_sentry/node+[...].mjs`. Remove this when the uploader matches paths literally. -function debugIdsOfFilesWithGlobCharacters(dir: string): Set { - const debugIds = new Set(); - - for (const file of filesContaining(dir, 'sentry-dbid-')) { - if (!/[[\]{}()*?]/.test(path.relative(dir, file))) { - continue; - } - - for (const match of fs.readFileSync(file, 'utf8').matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { - debugIds.add((match[1] as string).toLowerCase()); - } - } - - return debugIds; -} - console.log( `Variant: ${isStaticBuild ? 'nuxt generate' : 'nuxt build'}, ` + `client source maps ${keepClientSourceMaps ? 'kept' : 'deleted'}\n`, ); -const requests = loadMockServerResults(); - -console.log(`Captured ${requests.length} requests to mock Sentry server:\n`); -for (const request of requests) { - console.log(` ${request.method} ${request.url} (${request.bodySize} bytes)`); -} -console.log(''); - -// --- The upload reached Sentry --- - -assert.ok( - requests.some(r => r.authorization.includes('fake-auth-token')), - 'Expected requests with the configured auth token', +// Nuxt 5 writes the client build to `CLIENT_OUTPUT` directly. After Sentry deletes the client maps there, a chunk +// that never had a map looks the same as one whose map was deleted. So the client upload is checked only when the +// maps are kept. The upload is the same in all variants. +const outputDirsWithSourceMaps = [ + ...(keepClientSourceMaps ? [CLIENT_OUTPUT] : []), + ...(isStaticBuild ? [] : [SERVER_OUTPUT]), +]; +const debugIdsWithoutSourceMap = new Set( + outputDirsWithSourceMaps.flatMap(dir => [...debugIdsOfChunksWithoutSourceMap(dir)]), ); +const debugIdsSkippedByUploader = isStaticBuild + ? [] + : findDebugIdsInFilesWithGlobCharacters({ outputDir: SERVER_OUTPUT }); + +const { requests, bundles, sourcemaps, uploadedDebugIds } = assertSourceMapUpload({ + outputDirs: outputDirsWithSourceMaps, + // The client output is checked below, because the "kept" variant expects its maps to stay. + strippedDirs: [], + // Vite builds the client and Nitro builds the server. Counting bundles would still pass with either + // plugin dropped, so each side is pinned to a marker only that side's source supplies. + // Nitro 3 deletes `sourcesContent` from the server maps unless `experimental.sourcemapMinify` is `false`. The module + // sets it to `false`, which is the only reason the server marker survives into `sourcesContent`. + markers: { 'client source (Vite plugin)': CLIENT_MARKER, 'server source (Rollup plugin)': SERVER_MARKER }, + debugIdsWithoutSourceMap: [...debugIdsWithoutSourceMap, ...debugIdsSkippedByUploader], +}); +console.log(`Found ${bundles.length} artifact bundle(s)\n`); + +// --- Nuxt-specific checks on the upload --- // Sentry rejects a release that names no project with a 400, and the mock server accepts anything, // so assert on the body rather than on the call alone. @@ -127,92 +120,34 @@ assert.ok( 'Expected a GET for the chunk-upload options', ); -const chunkPosts = getChunkUploadPosts(requests); -assert.ok( - chunkPosts.some(r => r.bodySize > 0), - 'Expected at least one chunk upload POST with a non-empty body', -); - -const assembleRequests = getAssembleRequests(requests); -assert.ok(assembleRequests.length > 0, 'Expected at least one assemble request'); -for (const request of assembleRequests) { - assert.ok(request.assembleBody?.projects?.includes('test-project'), 'Expected assemble request for test-project'); - assert.equal(request.assembleBody?.version, 'test-release', 'Expected assemble request to reference the release'); +for (const request of getAssembleRequests(requests)) { assert.ok((request.assembleBody?.chunks?.length ?? 0) > 0, 'Expected assemble request to have chunk checksums'); for (const chunk of request.assembleBody?.chunks ?? []) { assert.match(chunk, /^[\da-f]{40}$/i, `Expected a SHA-1 chunk checksum, got: ${chunk}`); } } -const bundles = getArtifactBundles(requests); -assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest'); -console.log(`Found ${bundles.length} artifact bundle(s)\n`); - -// --- Both bundlers uploaded --- - -const sourcemaps = getSourcemaps(bundles); -assert.ok( - sourcemaps.some(map => map.sourcemap.mappings?.length), - 'Expected at least one uploaded sourcemap with non-empty mappings', -); - -const containsMarker = (marker: string): boolean => - sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))); - -// Vite builds the client and Nitro builds the server. Counting bundles would still pass with either -// plugin dropped, so each side is pinned to a marker only that side's source supplies. -assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded sourcemap carrying the client source (Vite plugin)'); -// Nitro 3 deletes `sourcesContent` from the server maps unless `experimental.sourcemapMinify` is `false`. The module -// sets it to `false`, which is the only reason this marker survives into `sourcesContent`. -assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded sourcemap carrying the server source (Rollup plugin)'); - // `rewriteSources` normalizes `../../../foo` to `./foo` so paths stay resolvable in Sentry. const unnormalizedSources = [...new Set(sourcemaps.flatMap(map => map.sourcemap.sources ?? []))].filter( source => source.startsWith('../') || path.isAbsolute(source), ); assert.deepEqual(unnormalizedSources, [], `Expected every uploaded source to be normalized to './…'`); -// --- Debug IDs tie the shipped bundle to the uploaded map --- - -const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase())); -assert.ok(uploadedDebugIds.size > 0, 'Expected at least one JS/sourcemap pair with matching debug IDs'); - const malformedDebugIds = [...uploadedDebugIds].filter(debugId => !UUID_REGEX.test(debugId)); assert.deepEqual(malformedDebugIds, [], 'Expected every uploaded debug ID to be a UUID'); -// An uploaded map is only reachable at runtime if the shipped bundle claims the same ID. Inspecting -// the upload alone cannot show this. -// Nuxt 5 writes the client build to `CLIENT_OUTPUT` directly. After Sentry deletes the client maps there, a chunk -// that never had a map looks the same as one whose map was deleted. So the client upload is checked only when the -// maps are kept. The upload is the same in all variants. -const outputDirsWithSourceMaps = [ - ...(keepClientSourceMaps ? [CLIENT_OUTPUT] : []), - ...(isStaticBuild ? [] : [SERVER_OUTPUT]), -]; -const debugIdsWithoutSourceMap = new Set( - outputDirsWithSourceMaps.flatMap(dir => [...debugIdsOfChunksWithoutSourceMap(dir)]), -); -const debugIdsSkippedByUploader = isStaticBuild ? new Set() : debugIdsOfFilesWithGlobCharacters(SERVER_OUTPUT); +// --- Every output directory ships at least one uploaded debug ID --- +// The shared assertions cover the directories that keep their maps. A directory whose maps are deleted +// must still ship a debug ID with an uploaded map, or a build that uploads nothing would pass. for (const outputDir of isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER_OUTPUT]) { const injectedDebugIds = findInjectedDebugIds({ outputDir }); assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); - // The exemption for chunks without a source map must not let a build without any uploaded map pass. assert.ok( injectedDebugIds.some(debugId => uploadedDebugIds.has(debugId)), `Expected at least one debug ID in ${outputDir} to have an uploaded sourcemap`, ); - if (outputDirsWithSourceMaps.includes(outputDir)) { - const unuploaded = injectedDebugIds.filter( - debugId => - !uploadedDebugIds.has(debugId) && - !debugIdsWithoutSourceMap.has(debugId) && - !debugIdsSkippedByUploader.has(debugId), - ); - assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded sourcemap`); - } - console.log(` ${outputDir}: ${injectedDebugIds.length} injected debug ID(s)`); // Nitro bundles the SSR build again, so its debug IDs can end up next to Nitro's own. diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/package.json index 88e1df2b5611..e2d35fd304b3 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/package.json @@ -25,9 +25,6 @@ "ts-node": "10.9.1", "typescript": "~5.0.0" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "optional": true, "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts index bdef334cfa88..8016834d1167 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts @@ -1,4 +1,6 @@ // https://nuxt.com/docs/api/configuration/nuxt-config +const tunnelVariant = !!process.env.E2E_TEST_TUNNEL; + export default defineNuxtConfig({ compatibilityDate: '2025-07-15', imports: { autoImport: false }, @@ -12,12 +14,17 @@ export default defineNuxtConfig({ '/rendering-modes/pre-rendered-page': { prerender: true }, }, + // The base-path variant serves the app, and so the tunnel, under `/app`. + ...(process.env.E2E_TEST_BASE_PATH && { app: { baseURL: `${process.env.E2E_TEST_BASE_PATH}/` } }), + modules: ['@pinia/nuxt', '@sentry/nuxt/module'], runtimeConfig: { public: { sentry: { - dsn: 'https://public@dsn.ingest.sentry.io/1337', + // The tunnel variant forwards to the test proxy, so the DSN has to point there. + dsn: tunnelVariant ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', }, + e2eTunnel: tunnelVariant, }, }, nitro: { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json index ef7d5c1fbc3c..bb03b840799c 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json @@ -15,7 +15,10 @@ "test:dev": "TEST_ENV=development playwright test environment", "test:build": "pnpm install && pnpm build", "test:build-canary": "pnpm add nuxt@npm:nuxt-nightly@latest && pnpm add nitro@npm:nitro-nightly@latest && pnpm install --force && pnpm build", - "test:assert": "pnpm test:prod && pnpm test:dev" + "test:assert": "pnpm test:prod && pnpm test:dev", + "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm build", + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-dev": "E2E_TEST_TUNNEL=1 TEST_ENV=development playwright test tunnel" }, "//": [ "Currently, we need to install the latest version of Nitro and the Nuxt nightlies as those contain Nuxt v5", @@ -38,9 +41,18 @@ "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" }, "sentryTest": { - "optional": true - }, - "volta": { - "extends": "../../package.json" + "optional": true, + "variants": [ + { + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath", + "label": "nuxt-5 (tunnel-basepath)" + }, + { + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev", + "label": "nuxt-5 (tunnel-dev)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts index 900df0811faa..f5a30830107b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts @@ -3,7 +3,7 @@ import { /* usePinia,*/ useRuntimeConfig } from '#imports'; Sentry.init({ dsn: useRuntimeConfig().public.sentry.dsn, - tunnel: `http://localhost:3031/`, // proxy server + tunnel: useRuntimeConfig().public.e2eTunnel ? true : `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, integrations: [ /* Sentry.piniaIntegration(usePinia(), { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts index 58d541b5228b..ab66d40e13c7 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts @@ -1,9 +1,12 @@ import * as Sentry from '@sentry/nuxt'; Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: process.env.E2E_TEST_TUNNEL ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1.0, // Capture 100% of the transactions tunnel: 'http://localhost:3031/', // proxy server // Several tests trigger the same server error one after another, which Dedupe would drop. - integrations: integrations => integrations.filter(integration => integration.name !== 'Dedupe'), + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'Dedupe'), + ...(process.env.E2E_TEST_TUNNEL ? [Sentry.serveTunnelIntegration()] : []), + ], }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts index 7216e9fc7560..62d2eebd07e0 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts @@ -1,7 +1,12 @@ import { defineHandler } from 'nitro'; -import { getQuery } from 'nitro/h3'; +import { getQuery, HTTPError } from 'nitro/h3'; export default defineHandler(async event => { + // The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. + if (process.env.E2E_TEST_TUNNEL && event.req.method === 'POST' && event.req.headers.get('x-auth') !== 'ok') { + throw new HTTPError({ status: 401, statusText: 'blocked by user auth' }); + } + // Check if we should throw an error const query = getQuery(event); if (query.throwError === 'true') { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tunnel.test.ts new file mode 100644 index 000000000000..51e8a022c623 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tunnel.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from '@playwright/test'; +import { isServerSpanAt, watchForTunnelSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +test.beforeAll(async ({ request }) => { + // `nuxt dev` opens the port before it's ready and answers 503 until then, and Playwright only waits for the port. + await expect + .poll(async () => (await request.get(`${process.env.E2E_TEST_BASE_PATH ?? ''}/`)).status(), { timeout: 30_000 }) + .not.toBe(503); +}); + +const BASE_PATH = process.env.E2E_TEST_BASE_PATH ?? ''; +const TUNNEL_PATH = new RegExp(`^${BASE_PATH}/[a-z0-9]{1,7}$`); + +test('answers the managed tunnel before the app auth middleware', async ({ page }) => { + // The variant's auth middleware rejects every other POST. + expect((await page.request.post(`${BASE_PATH}/api/anything`, { data: 'x' })).status()).toBe(401); + + const raceTunnelSpan = watchForTunnelSpan('nuxt-5', isServerSpanAt(TUNNEL_PATH)); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto(`${BASE_PATH}/`); + + expect((await tunnelResponsePromise).status()).toBe(200); + expect( + await raceTunnelSpan(isServerSpanAt(`${BASE_PATH}/api/test-param/123`), () => + page.request.get(`${BASE_PATH}/api/test-param/123`), + ), + ).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-17-static/package.json b/dev-packages/e2e-tests/test-applications/react-17-static/package.json index 6fd2d11b7272..c594a261f637 100644 --- a/dev-packages/e2e-tests/test-applications/react-17-static/package.json +++ b/dev-packages/e2e-tests/test-applications/react-17-static/package.json @@ -44,8 +44,5 @@ "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "serve": "14.0.1" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/react-17-static/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-17-static/tests/transactions.test.ts index 835998af23b3..c57a52e6b891 100644 --- a/dev-packages/e2e-tests/test-applications/react-17-static/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-17-static/tests/transactions.test.ts @@ -105,7 +105,7 @@ test('sends an INP span', async ({ page }) => { expect(inpSpan.attributes).toEqual( expect.objectContaining({ 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, - 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, + 'sentry.origin': { value: 'auto.ui.browser.inp', type: 'string' }, 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, 'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, 'sentry.transaction': { value: '/', type: 'string' }, diff --git a/dev-packages/e2e-tests/test-applications/react-17/package.json b/dev-packages/e2e-tests/test-applications/react-17/package.json index 1c58df6603e6..afae577f2e94 100644 --- a/dev-packages/e2e-tests/test-applications/react-17/package.json +++ b/dev-packages/e2e-tests/test-applications/react-17/package.json @@ -44,8 +44,5 @@ "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "serve": "14.0.1" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/react-17/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-17/tests/spans.test.ts index 6eba5f11e4a2..d5fdf3e9342d 100644 --- a/dev-packages/e2e-tests/test-applications/react-17/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-17/tests/spans.test.ts @@ -88,7 +88,7 @@ test('sends an INP span', async ({ page }) => { expect(inpSpan.attributes).toEqual( expect.objectContaining({ 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, - 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, + 'sentry.origin': { value: 'auto.ui.browser.inp', type: 'string' }, 'browser.web_vital.inp.target': { value: 'body > div#root > input#exception-button[type="button"]', type: 'string', diff --git a/dev-packages/e2e-tests/test-applications/react-19/package.json b/dev-packages/e2e-tests/test-applications/react-19/package.json index 136ea2fb3da7..157db2de17d6 100644 --- a/dev-packages/e2e-tests/test-applications/react-19/package.json +++ b/dev-packages/e2e-tests/test-applications/react-19/package.json @@ -45,8 +45,5 @@ "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "serve": "14.0.1" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/package.json b/dev-packages/e2e-tests/test-applications/react-create-browser-router/package.json index d623410d8089..4405ba83c8c8 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-browser-router/package.json +++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/package.json @@ -44,8 +44,5 @@ "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "serve": "14.0.1" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/package.json b/dev-packages/e2e-tests/test-applications/react-create-hash-router/package.json index 9c3c47168004..a6c572225b97 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/package.json +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/package.json @@ -45,8 +45,5 @@ "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "serve": "14.0.1" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/react-create-memory-router/package.json b/dev-packages/e2e-tests/test-applications/react-create-memory-router/package.json index cb4ca7a51512..419a9e289d61 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-memory-router/package.json +++ b/dev-packages/e2e-tests/test-applications/react-create-memory-router/package.json @@ -44,8 +44,5 @@ "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "serve": "14.0.1" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/package.json b/dev-packages/e2e-tests/test-applications/react-router-5/package.json index 76c415c4711e..34d06520e4e9 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-5/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-5/package.json @@ -47,8 +47,5 @@ "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "serve": "14.0.1" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/package.json b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/package.json index 615dd4e6c8c7..16af3288c98c 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/package.json @@ -47,8 +47,5 @@ "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "serve": "14.0.1", "npm-run-all2": "^6.2.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/package.json b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/package.json index 7b3dc28177ef..5e76a677c8e6 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/package.json @@ -45,8 +45,5 @@ "last 1 firefox version", "last 1 safari version" ] - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/package.json b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/package.json index 16a4760a257d..87ddbf4ab3f8 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/package.json @@ -43,8 +43,5 @@ "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "serve": "14.0.1" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/react-router-6/package.json b/dev-packages/e2e-tests/test-applications/react-router-6/package.json index 2ee798930dc4..eabd1c9498b6 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-6/package.json @@ -48,7 +48,7 @@ "serve": "14.0.1", "npm-run-all2": "^6.2.0" }, - "volta": { - "extends": "../../package.json" + "sentryTest": { + "runner": "namespace-profile-sentry-javascript" } } diff --git a/dev-packages/e2e-tests/test-applications/react-router-6/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6/tests/spans.test.ts index ecd63899dfee..a47722acba77 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-6/tests/spans.test.ts @@ -88,7 +88,7 @@ test('sends an INP span', async ({ page }) => { expect(inpSpan.attributes).toEqual( expect.objectContaining({ 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, - 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, + 'sentry.origin': { value: 'auto.ui.browser.inp', type: 'string' }, 'browser.web_vital.inp.target': { value: 'body > div#root > input#exception-button[type="button"]', type: 'string', diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/package.json index 34d346d53abe..a546ef6ddb59 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/package.json @@ -45,8 +45,5 @@ "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "serve": "14.0.1", "npm-run-all2": "^6.2.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes.ts index 9f0dd2325c42..5a9fa9c841aa 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes.ts @@ -11,7 +11,7 @@ export default [ route('server-action', 'routes/performance/server-action.tsx'), route('with-middleware', 'routes/performance/with-middleware.tsx'), route('multi-middleware', 'routes/performance/multi-middleware.tsx'), - route('error-loader', 'routes/performance/error-loader.tsx'), + route('error-loader/:id', 'routes/performance/error-loader.tsx'), route('error-action', 'routes/performance/error-action.tsx'), route('error-middleware', 'routes/performance/error-middleware.tsx'), route('lazy-route', 'routes/performance/lazy-route.tsx'), diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/error-loader.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/error-loader.tsx index 6dd3d3013f37..b94363688a95 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/error-loader.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/error-loader.tsx @@ -1,5 +1,7 @@ -export function loader(): never { - throw new Error('Loader error for testing'); +import type { Route } from './+types/error-loader'; + +export function loader({ params }: Route.LoaderArgs): never { + throw new Error(`Loader error for testing (${params.id})`); } export default function ErrorLoaderPage() { diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/package.json index be9ce743d3e5..ad3dd46d1d6c 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/package.json @@ -57,9 +57,6 @@ "last 1 safari version" ] }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "optional": true } diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/errors/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/errors/errors.server.test.ts index 10cf2e5be8e0..08863ed8d9c5 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/errors/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/errors/errors.server.test.ts @@ -1,4 +1,5 @@ import { expect, test } from '@playwright/test'; +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; import { collectStreamedSpansUntilSegment, getSpanOp, @@ -7,17 +8,24 @@ import { } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; +function isErrorLoaderSegment(span: SerializedStreamedSpan, id: string): boolean { + const url = span.attributes['url.full']?.value; + return span.is_segment && typeof url === 'string' && url.endsWith(`/performance/error-loader/${id}`); +} + test.describe('server - instrumentation API error capture', () => { + // Each loader test hits its own URL: streamed spans flush later than errors, so a segment span + // (or error) from an earlier test on the same URL could otherwise be matched by a later test. test('should capture loader errors with instrumentation API mechanism', async ({ page }) => { const errorPromise = waitForError(APP_NAME, async errorEvent => { - return errorEvent.exception?.values?.[0]?.value === 'Loader error for testing'; + return errorEvent.exception?.values?.[0]?.value === 'Loader error for testing (mechanism)'; }); const spanPromise = waitForStreamedSpan(APP_NAME, span => { - return span.name === 'GET /performance/error-loader' && span.is_segment; + return isErrorLoaderSegment(span, 'mechanism'); }); - await page.goto(`/performance/error-loader`).catch(() => { + await page.goto(`/performance/error-loader/mechanism`).catch(() => { // Expected to fail due to loader error }); @@ -29,7 +37,7 @@ test.describe('server - instrumentation API error capture', () => { values: [ { type: 'Error', - value: 'Loader error for testing', + value: 'Loader error for testing (mechanism)', mechanism: { type: 'react_router.loader', handled: false, @@ -37,19 +45,19 @@ test.describe('server - instrumentation API error capture', () => { }, ], }, - transaction: 'GET /performance/error-loader', + transaction: 'GET /performance/error-loader/:id', }); // Verify the transaction was also created with correct attributes - expect(span.name).toBe('GET /performance/error-loader'); + expect(span.name).toBe('GET /performance/error-loader/:id'); expect(getSpanOp(span)).toBe('http.server'); expect(span.attributes['sentry.origin']?.value).toBe('auto.http.react_router.instrumentation_api'); }); test('should include loader span in the segment even when loader throws', async ({ page }) => { - const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /performance/error-loader'); + const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, span => isErrorLoaderSegment(span, 'loader-span')); - await page.goto(`/performance/error-loader`).catch(() => { + await page.goto(`/performance/error-loader/loader-span`).catch(() => { // Expected to fail due to loader error }); @@ -69,20 +77,20 @@ test.describe('server - instrumentation API error capture', () => { test('error and segment span should share the same trace', async ({ page }) => { const errorPromise = waitForError(APP_NAME, async errorEvent => { - return errorEvent.exception?.values?.[0]?.value === 'Loader error for testing'; + return errorEvent.exception?.values?.[0]?.value === 'Loader error for testing (same-trace)'; }); const spanPromise = waitForStreamedSpan(APP_NAME, span => { - return span.name === 'GET /performance/error-loader' && span.is_segment; + return isErrorLoaderSegment(span, 'same-trace'); }); - await page.goto(`/performance/error-loader`).catch(() => { + await page.goto(`/performance/error-loader/same-trace`).catch(() => { // Expected to fail due to loader error }); const [error, span] = await Promise.all([errorPromise, spanPromise]); - // Error and segment span should have the same trace_id + expect(error.contexts?.trace?.trace_id).toMatch(/^[a-f0-9]{32}$/); expect(error.contexts?.trace?.trace_id).toBe(span.trace_id); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/package.json index 112dde94b5d8..6c93eacbf3d3 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/package.json @@ -49,8 +49,5 @@ "last 1 firefox version", "last 1 safari version" ] - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/package.json index ff2f22e6f53c..7a5ccf0d4c6a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/package.json @@ -52,8 +52,5 @@ "last 1 firefox version", "last 1 safari version" ] - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework/package.json index 7b6814661dca..e0395c3ccad8 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/package.json @@ -54,9 +54,6 @@ "last 1 safari version" ] }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "variants": [ { diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/package.json index 561d8a362f29..7d01de919ad9 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/package.json @@ -46,7 +46,7 @@ "serve": "14.0.1", "npm-run-all2": "^6.2.0" }, - "volta": { - "extends": "../../package.json" + "sentryTest": { + "runner": "namespace-profile-sentry-javascript" } } diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/package.json index 3847a43b99e1..cfd7ece927fc 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/package.json @@ -45,8 +45,5 @@ "last 1 firefox version", "last 1 safari version" ] - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/package.json index e1e5919dfb24..393dbaba42a1 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/package.json @@ -44,8 +44,5 @@ "last 1 firefox version", "last 1 safari version" ] - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts index 3d0e91e50b60..2c09be78eb0f 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts @@ -70,6 +70,6 @@ test('sends an INP span', async ({ page }) => { expect(inpSpan.span_id).toMatch(/[a-f0-9]{16}/); expect(inpSpan.end_timestamp).toBeGreaterThan(inpSpan.start_timestamp); expect(inpSpan.attributes['sentry.op']?.value).toBe('ui.interaction.click'); - expect(inpSpan.attributes['sentry.origin']?.value).toBe('auto.http.browser.inp'); + expect(inpSpan.attributes['sentry.origin']?.value).toBe('auto.ui.browser.inp'); expect(inpSpan.attributes['sentry.exclusive_time']?.value).toEqual(expect.any(Number)); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-spa/package.json index 5840ca72a034..5fc76a65c127 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-spa/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/package.json @@ -44,8 +44,5 @@ "last 1 firefox version", "last 1 safari version" ] - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/spans.test.ts index 65ae46c0e1d4..b177a028ded0 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/spans.test.ts @@ -88,7 +88,7 @@ test('sends an INP span', async ({ page }) => { expect(inpSpan.attributes).toEqual( expect.objectContaining({ 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, - 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, + 'sentry.origin': { value: 'auto.ui.browser.inp', type: 'string' }, 'browser.web_vital.inp.target': { value: 'body > div#root > input#exception-button[type="button"]', type: 'string', diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/package.json b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/package.json index 4d248a95660a..9367f20cc478 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/package.json @@ -45,8 +45,5 @@ "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "serve": "14.0.1", "npm-run-all2": "^6.2.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.client.tsx index 005268b40ad0..0215cdd728b9 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.client.tsx @@ -6,8 +6,8 @@ import { HydratedRouter } from 'react-router/dom'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions // todo: get this from env - dsn: 'https://username@domain/123', - tunnel: `http://localhost:3031/`, // proxy server + dsn: __E2E_TEST_TUNNEL__ ? 'http://public@localhost:3031/1337' : 'https://username@domain/123', + tunnel: __E2E_TEST_TUNNEL__ ? true : `http://localhost:3031/`, // proxy server integrations: [Sentry.reactRouterTracingIntegration()], tracesSampleRate: 1.0, tracePropagationTargets: [/^\//], diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.server.cloudflare.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.server.cloudflare.tsx index 31a4fec981f6..57bc9460c40c 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.server.cloudflare.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.server.cloudflare.tsx @@ -1,44 +1,13 @@ import { captureException } from '@sentry/cloudflare'; import * as Sentry from '@sentry/react-router/cloudflare'; -import { isbot } from 'isbot'; import { renderToReadableStream } from 'react-dom/server'; -import { type EntryContext, type HandleErrorFunction, ServerRouter } from 'react-router'; +import { ServerRouter, type HandleErrorFunction } from 'react-router'; -async function handleRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - routerContext: EntryContext, -): Promise { - let shellRendered = false; - const userAgent = request.headers.get('user-agent'); - const nonce = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(16)))); - - const body = await renderToReadableStream(, { - nonce, - signal: request.signal, - onError(error: unknown) { - responseStatusCode = 500; - // Errors thrown after the shell has flushed can't change the status code, so surface them. - if (shellRendered) { - // eslint-disable-next-line no-console - console.error(error); - } - }, - }); - shellRendered = true; - - if (userAgent && isbot(userAgent)) { - await body.allReady; - } - - responseHeaders.set('Content-Type', 'text/html'); - - return new Response(Sentry.injectTraceMetaTags(body), { - headers: responseHeaders, - status: responseStatusCode, - }); -} +export default Sentry.createSentryHandleRequest({ + renderToReadableStream, + ServerRouter, + getNonce: () => btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(16)))), +}); export const handleError: HandleErrorFunction = (error, { request }) => { if (!request.signal.aborted) { @@ -47,6 +16,4 @@ export const handleError: HandleErrorFunction = (error, { request }) => { } }; -export default Sentry.wrapSentryHandleRequest(handleRequest); - export const instrumentations = [Sentry.createSentryServerInstrumentation()]; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/globals.d.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/globals.d.ts new file mode 100644 index 000000000000..dc2e8cbcb587 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/globals.d.ts @@ -0,0 +1 @@ +declare const __E2E_TEST_TUNNEL__: boolean; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/root.tsx index bc1b8f1236c0..3ae4b730b9e1 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/root.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/root.tsx @@ -2,6 +2,20 @@ import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse } import type { Route } from './+types/root'; import stylesheet from './app.css?url'; +// The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. Other variants +// get none, so their span assertions stay as they are. +export const middleware: Route.MiddlewareFunction[] = __E2E_TEST_TUNNEL__ + ? [ + ({ request }, next) => { + if (request.method === 'POST' && request.headers.get('x-auth') !== 'ok') { + return new Response('blocked by user auth', { status: 401 }); + } + + return next(); + }, + ] + : []; + export const links: Route.LinksFunction = () => [ { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, { diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-framework/instrument.mjs index c16240141b6d..28a12bc0fe8d 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/instrument.mjs @@ -1,8 +1,12 @@ import * as Sentry from '@sentry/react-router'; Sentry.init({ - dsn: 'https://username@domain/123', + dsn: process.env.E2E_TEST_TUNNEL ? 'http://public@localhost:3031/1337' : 'https://username@domain/123', environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, tunnel: `http://localhost:3031/`, // proxy server + // The tunnel variants also accept another app's DSN, so the server serves more than one tunnel path. + integrations: process.env.E2E_TEST_TUNNEL + ? [Sentry.serveTunnelIntegration({ allowedDsns: ['http://other@localhost:3031/1338'] })] + : [], }); 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 de5503c7626a..56c20ce35f2d 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 @@ -45,7 +45,10 @@ "test:build": "pnpm install && pnpm build", "test:assert": "pnpm test:ts && pnpm test:playwright", "test:ts": "pnpm typecheck", - "test:playwright": "playwright test" + "test:playwright": "playwright test", + "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm build", + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm test:playwright tunnel", + "test:assert:tunnel-basepath-dev": "TEST_ENV=development E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm test:playwright tunnel" }, "eslintConfig": { "extends": [ @@ -65,10 +68,8 @@ "last 1 safari version" ] }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { + "runner": "namespace-profile-sentry-javascript", "variants": [ { "build-command": "pnpm test:build-latest", @@ -86,6 +87,22 @@ { "runtime": "cloudflare", "label": "react-router-8-framework (cloudflare)" + }, + { + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath", + "label": "react-router-8-framework (tunnel-basepath)" + }, + { + "runtime": "cloudflare", + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath", + "label": "react-router-8-framework (tunnel-basepath-cloudflare)" + }, + { + "label": "react-router-8-framework (tunnel-basepath-dev)", + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-basepath-dev" } ] } diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-framework/playwright.config.mjs index 6e93d6fc020e..2fa8a93bb8bc 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/playwright.config.mjs @@ -10,7 +10,7 @@ const startCommands = { const config = getPlaywrightConfig( { - startCommand: startCommands[getRuntime()], + startCommand: process.env.TEST_ENV === 'development' ? 'pnpm dev --port 3030' : startCommands[getRuntime()], port: 3030, }, // Boot Redis before the tests run, outside the webServer startup-timeout window. diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/react-router.config.ts index bb1f96469dd2..a74ae0b46048 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/react-router.config.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/react-router.config.ts @@ -2,5 +2,7 @@ import type { Config } from '@react-router/dev/config'; export default { ssr: true, + // The base-path variant serves the app, and so the tunnel, under `/app`. + ...(process.env.E2E_TEST_BASE_PATH && { basename: `${process.env.E2E_TEST_BASE_PATH}/` }), prerender: ['/performance/static'], } satisfies Config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/tunnel.test.ts new file mode 100644 index 000000000000..916ea878d170 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/tunnel.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from '@playwright/test'; +import { isServerSpanAt, watchForTunnelSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const APP_NAME = 'react-router-8-framework'; +const BASE_PATH = process.env.E2E_TEST_BASE_PATH ?? ''; +const TUNNEL_PATH = new RegExp(`^${BASE_PATH}/[a-z0-9]{1,7}$`); + +test('answers the managed tunnel before the root middleware', async ({ page }) => { + // The variant's root middleware rejects every other POST. + expect((await page.request.post(`${BASE_PATH}/`, { data: 'x' })).status()).toBe(401); + + const raceTunnelSpan = watchForTunnelSpan(APP_NAME, isServerSpanAt(TUNNEL_PATH)); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto(`${BASE_PATH}/`); + + expect((await tunnelResponsePromise).status()).toBe(200); + expect( + await raceTunnelSpan(isServerSpanAt(`${BASE_PATH}/performance`), () => + page.request.get(`${BASE_PATH}/performance`), + ), + ).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts index 002bb3a77fbe..032293400dbf 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts @@ -5,6 +5,9 @@ import { sentryReactRouter } from '@sentry/react-router/vite'; import { defineConfig } from 'vite'; export default defineConfig(async config => ({ + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + }, plugins: [ cloudflare({ viteEnvironment: { name: 'ssr' } }), reactRouter(), diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts index 0fe4cdde8c7a..3663eb2a74b5 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts @@ -4,6 +4,9 @@ import { defineConfig } from 'vite'; export default defineConfig(config => { return { + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + }, plugins: [reactRouter(), sentryReactRouter({ sourcemaps: { disable: true } }, config)], }; }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/workers/instrument.server.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/workers/instrument.server.ts index be998ae29a13..ac69c92c81b6 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/workers/instrument.server.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/workers/instrument.server.ts @@ -1,8 +1,11 @@ -import { defineCloudflareOptions } from '@sentry/cloudflare'; +import { defineCloudflareOptions, serveTunnelIntegration } from '@sentry/cloudflare'; + +declare const __E2E_TEST_TUNNEL__: boolean; export default defineCloudflareOptions({ - dsn: 'https://username@domain/123', + dsn: __E2E_TEST_TUNNEL__ ? 'http://public@localhost:3031/1337' : 'https://username@domain/123', environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, tunnel: 'http://localhost:3031/', // proxy server + integrations: __E2E_TEST_TUNNEL__ ? [serveTunnelIntegration()] : [], }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/package.json b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/package.json index a4fb1e056324..9ad40aab4ad5 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/package.json @@ -45,8 +45,5 @@ "last 1 firefox version", "last 1 safari version" ] - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-spa/package.json b/dev-packages/e2e-tests/test-applications/react-router-8-spa/package.json index 7521d43a3d74..d69fbb0eb1e7 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-spa/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/package.json @@ -44,8 +44,5 @@ "last 1 firefox version", "last 1 safari version" ] - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/spans.test.ts index 6c9ad5efc502..a6bf22054ff5 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/spans.test.ts @@ -88,7 +88,7 @@ test('sends an INP span', async ({ page }) => { expect(inpSpan.attributes).toEqual( expect.objectContaining({ 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, - 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, + 'sentry.origin': { value: 'auto.ui.browser.inp', type: 'string' }, 'browser.web_vital.inp.target': { value: 'body > div#root > input#exception-button[type="button"]', type: 'string', diff --git a/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/package.json index 28c54ab68ce5..cc3e75e3269a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/package.json @@ -28,8 +28,5 @@ "clean": "npx rimraf node_modules pnpm-lock.yaml", "test:build": "pnpm install && pnpm build", "test:assert": "pnpm tsx assert-build.ts" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/package.json index 6f488a3c7941..78309062e685 100644 --- a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/package.json +++ b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/package.json @@ -44,9 +44,6 @@ "@playwright/test": "1.64.1", "serve": "14.0.1" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "optional": true } diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json b/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json index 18de10d86b4a..f14be2a9bcbe 100644 --- a/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json @@ -50,8 +50,5 @@ "typescript": "^5.2.2", "vite": "^5.1.0", "vite-tsconfig-paths": "^4.3.1" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/package.json b/dev-packages/e2e-tests/test-applications/remix-server-timing/package.json index 8eb6004494fb..ee150587205c 100644 --- a/dev-packages/e2e-tests/test-applications/remix-server-timing/package.json +++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/package.json @@ -32,8 +32,5 @@ "typescript": "^5.1.6", "vite": "^5.4.11", "vite-tsconfig-paths": "^4.2.1" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/entry.client.tsx index 9a290b88c9a4..1f65a0f9be7b 100644 --- a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/entry.client.tsx @@ -1,3 +1,4 @@ +// SOURCEMAP_MARKER_CLIENT import { RemixBrowser } from '@remix-run/react'; import { StrictMode, startTransition } from 'react'; import { hydrateRoot } from 'react-dom/client'; diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/lib/greeting.server.ts b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/lib/greeting.server.ts new file mode 100644 index 000000000000..02c2ba99b548 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/lib/greeting.server.ts @@ -0,0 +1,4 @@ +// SOURCEMAP_MARKER_SERVER +export function getServerGreeting(): string { + return 'Hello from the server'; +} diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/routes/_index.tsx b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/routes/_index.tsx index ea184ec80a18..f2c03a8c2682 100644 --- a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/routes/_index.tsx +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/routes/_index.tsx @@ -1,3 +1,12 @@ +import { json } from '@remix-run/node'; +import { useLoaderData } from '@remix-run/react'; +import { getServerGreeting } from '../lib/greeting.server'; + +export function loader() { + return json({ greeting: getServerGreeting() }); +} + export default function Index() { - return

Sourcemaps test app

; + const { greeting } = useLoaderData(); + return

Sourcemaps test app: {greeting}

; } diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/assert-build.ts index 7f4ae319867b..64ee74111b0f 100644 --- a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/assert-build.ts @@ -1,10 +1,12 @@ import * as assert from 'assert/strict'; import * as fs from 'fs'; import * as path from 'path'; -import { getArtifactBundles, getDebugIdPairs, getSourcemaps, loadMockServerResults } from '@sentry-internal/test-utils'; +import { assertSourceMapUpload } from '@sentry-internal/test-utils'; const BUILD_DIR = 'build'; -const CLIENT_ASSETS_DIR = 'build/client/assets'; +const CLIENT_OUTPUT = path.join(BUILD_DIR, 'client'); +const SERVER_OUTPUT = path.join(BUILD_DIR, 'server'); +const CLIENT_ASSETS_DIR = path.join(CLIENT_OUTPUT, 'assets'); // Both injectors write this assignment, so counting it per file counts injections regardless of // which one ran. Matching only the bundler plugin's trailing `_sentryDebugIdIdentifier` would miss @@ -12,117 +14,38 @@ const CLIENT_ASSETS_DIR = 'build/client/assets'; const DEBUG_ID_ASSIGNMENT = /_sentryDebugIds\[[^\]]+\]\s*=\s*"([\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})"/gi; -function getClientChunks(): string[] { - assert.ok(fs.existsSync(CLIENT_ASSETS_DIR), `Expected ${CLIENT_ASSETS_DIR} to exist. Did the build run?`); +assert.ok(fs.existsSync(CLIENT_ASSETS_DIR), `Expected ${CLIENT_ASSETS_DIR} to exist. Did the build run?`); - return fs - .readdirSync(CLIENT_ASSETS_DIR) - .filter(file => file.endsWith('.js')) - .map(file => path.join(CLIENT_ASSETS_DIR, file)); -} - -const chunks = getClientChunks(); +const chunks = fs + .readdirSync(CLIENT_ASSETS_DIR) + .filter(file => file.endsWith('.js')) + .map(file => path.join(CLIENT_ASSETS_DIR, file)); assert.ok(chunks.length > 0, `Expected at least one client chunk in ${CLIENT_ASSETS_DIR}`); -// 1. No chunk carries more than one debug ID. +// No chunk carries more than one debug ID. // // Two injections per chunk is the failure mode of // https://github.com/getsentry/sentry-javascript/issues/22929: both snippets run at runtime, // `applyDebugIds` flattens them to a single filename, and the last one wins. For Remix that happens // when an app keeps `npx @sentry/remix --upload-sourcemaps` in its build script alongside the Vite // plugin, so the CLI injects a second ID over the plugin's. -const injectedDebugIds = new Map(); - for (const chunk of chunks) { - const code = fs.readFileSync(chunk, 'utf-8'); - const ids = [...code.matchAll(DEBUG_ID_ASSIGNMENT)].map(match => match[1] as string); + const ids = [...fs.readFileSync(chunk, 'utf-8').matchAll(DEBUG_ID_ASSIGNMENT)].map(match => match[1] as string); assert.ok( ids.length <= 1, `Expected at most one debug ID in ${chunk}, found ${ids.length}: ${JSON.stringify([...new Set(ids)])}.`, ); - - if (ids.length === 1) { - injectedDebugIds.set(chunk, ids[0] as string); - } } - -console.log(`no client chunk carries more than one debug ID (${injectedDebugIds.size}/${chunks.length} carry one)\n`); - -const requests = loadMockServerResults(); -const bundles = getArtifactBundles(requests); -assert.ok(bundles.length > 0, 'Expected at least one uploaded artifact bundle'); - -// 2. Source maps with real content reached Sentry. -// -// Asserting on the upload rather than on disk, because deleting the maps after a successful upload -// is the intended behaviour - the plugin defaults `filesToDeleteAfterUpload` when the app does not -// configure source maps itself. -const uploadedSourcemaps = getSourcemaps(bundles); -assert.ok(uploadedSourcemaps.length > 0, 'Expected at least one source map in the uploaded artifact bundles'); -assert.ok( - uploadedSourcemaps.some(entry => (entry.sourcemap.mappings?.length ?? 0) > 0), - 'Expected at least one uploaded source map with non-empty mappings', -); -console.log(`${uploadedSourcemaps.length} source map(s) uploaded with content`); - -// 3. The debug IDs that shipped are the ones that were uploaded. -// -// This is what actually breaks un-minification: a chunk can carry a perfectly valid debug ID that -// has no artifact bundle behind it. -const debugIdPairs = getDebugIdPairs(bundles); -const uploadedDebugIds = new Set(debugIdPairs.map(pair => pair.debugId.toLowerCase())); -assert.ok(uploadedDebugIds.size > 0, 'Expected at least one uploaded JS/source map pair with a debug ID'); - -// The uploaded artifacts are named after the debug ID (`~/-.js`), not after the chunk -// they came from, so the two file name sets never line up. Cross-check the IDs themselves: every -// debug ID that shipped has to have an artifact bundle behind it. -let crossCheckedChunks = 0; - -for (const [chunk, injectedDebugId] of injectedDebugIds) { - assert.ok( - uploadedDebugIds.has(injectedDebugId.toLowerCase()), - `Debug ID ${injectedDebugId} in ${chunk} was never uploaded.\n` + - `Uploaded debug IDs: ${JSON.stringify([...uploadedDebugIds])}`, - ); - crossCheckedChunks++; -} - -assert.ok( - crossCheckedChunks > 0, - 'Expected at least one chunk carrying a debug ID to cross-check against the upload.\n' + - `Client chunks: ${JSON.stringify(chunks.map(chunk => path.basename(chunk)))}\n` + - `Uploaded debug IDs: ${JSON.stringify([...uploadedDebugIds])}\n` + - `Uploaded JS urls: ${JSON.stringify(debugIdPairs.map(pair => pair.jsUrl))}`, -); -console.log(`${crossCheckedChunks} chunk(s) ship a debug ID that was uploaded\n`); - -// 4. No source map survived the build. -// -// The plugin defaults `filesToDeleteAfterUpload` when the app configures no source map setting, so -// a leftover `.map` means one of the builds was never cleaned up. Remix runs a client and an SSR -// pass with different `outDir`s, while the deletion glob is held in a promise that settles once - -// this is what catches the second pass being left behind. -function findSourceMaps(dir: string): string[] { - return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { - const entryPath = path.join(dir, entry.name); - - if (entry.isDirectory()) { - return findSourceMaps(entryPath); - } - - return entry.name.endsWith('.map') ? [entryPath] : []; - }); -} - -const leftoverSourceMaps = findSourceMaps(BUILD_DIR); -assert.deepEqual( - leftoverSourceMaps, - [], - `Expected every source map to be deleted after upload, found ${leftoverSourceMaps.length}:\n${leftoverSourceMaps.join( - '\n', - )}`, -); -console.log('no source maps left in the build output\n'); - -console.log('All remix source map assertions passed!'); +console.log(`no client chunk carries more than one debug ID (${chunks.length} chunks)\n`); + +// The plugin defaults `filesToDeleteAfterUpload` when the app configures no source map setting. Remix +// runs a client and an SSR pass with different `outDir`s, while the deletion glob is held in a promise +// that settles once, so a leftover `.map` means the second pass was left behind. +assertSourceMapUpload({ + outputDirs: [CLIENT_OUTPUT, SERVER_OUTPUT], + strippedDirs: [BUILD_DIR], + markers: { 'client source': 'SOURCEMAP_MARKER_CLIENT', 'server source': 'SOURCEMAP_MARKER_SERVER' }, +}); + +console.log('All sourcemap assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/package.json index 8c0d51b24a99..e8f95517358c 100644 --- a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/package.json @@ -26,8 +26,5 @@ "tsx": "^4.23.0", "typescript": "^5.1.6", "vite": "^5.4.11" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx index 95b3ac655fec..471677646766 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx @@ -30,6 +30,12 @@ function HomePage(handle: Handle>) { User + + User (prefetch on intent) + + + User (prefetch in viewport) + diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts index bbeee228783a..89bdd61f5491 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts @@ -9,7 +9,7 @@ declare const process: { env: Record }; Sentry.init({ dsn: process.env.E2E_TEST_DSN, - tunnel: 'http://localhost:3061/', + tunnel: process.env.E2E_TEST_TUNNEL ? true : 'http://localhost:3061/', tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts index 345e6b2eaffd..4bfe6672caf4 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts @@ -15,6 +15,7 @@ export const assets = createAssetServer({ // into a browser module. The asset server substitutes these when it compiles. define: { 'process.env.E2E_TEST_DSN': JSON.stringify(process.env.E2E_TEST_DSN), + 'process.env.E2E_TEST_TUNNEL': JSON.stringify(process.env.E2E_TEST_TUNNEL ?? ''), }, }, }); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/router.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/router.ts index 03724dd7ea27..2102a5768372 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/router.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/router.ts @@ -14,8 +14,21 @@ declare module 'remix/router' { } } +// The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. +function authMiddleware(context: { request: Request }, next: () => Promise): Promise | Response { + if ( + process.env.E2E_TEST_TUNNEL && + context.request.method === 'POST' && + context.request.headers.get('x-auth') !== 'ok' + ) { + return new Response('blocked by user auth', { status: 401 }); + } + + return next(); +} + export const router = createRouter({ - middleware: [renderMiddleware], + middleware: [authMiddleware, renderMiddleware], }); router.map(routes, controller); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/package.json b/dev-packages/e2e-tests/test-applications/remix-v3/package.json index 11208a42ee98..7d52c7d0d8a1 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/package.json +++ b/dev-packages/e2e-tests/test-applications/remix-v3/package.json @@ -8,11 +8,13 @@ "test": "playwright test", "clean": "npx rimraf node_modules pnpm-lock.yaml", "test:build": "pnpm install", - "test:assert": "pnpm test" + "test:assert": "pnpm test", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3061/1337 playwright test tunnel", + "test:assert:tunnel-plain-server": "E2E_TEST_PLAIN_SERVER=1 pnpm test:assert:tunnel" }, "dependencies": { "@sentry/remix": "file:../../packed/sentry-remix-packed.tgz", - "remix": "3.0.0" + "remix": "3.1.0" }, "devDependencies": { "@playwright/test": "1.64.1", @@ -21,7 +23,18 @@ "@types/node": "^24.6.0", "typescript": "~5.9.0" }, - "volta": { - "extends": "../../package.json" + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build", + "assert-command": "pnpm test:assert:tunnel", + "label": "remix-v3 (tunnel)" + }, + { + "build-command": "pnpm test:build", + "assert-command": "pnpm test:assert:tunnel-plain-server", + "label": "remix-v3 (tunnel, plain server)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/server.ts b/dev-packages/e2e-tests/test-applications/remix-v3/server.ts index c9ac4f00d087..d2b2bbf7a0e6 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/server.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/server.ts @@ -1,5 +1,6 @@ import * as Sentry from '@sentry/remix/v3'; import * as http from 'node:http'; +import { Readable } from 'node:stream'; import { createRequestListener } from 'remix/node-fetch-server'; import { router } from './app/router.ts'; @@ -7,21 +8,59 @@ import { router } from './app/router.ts'; Sentry.init({ dsn: process.env.E2E_TEST_DSN, tunnel: 'http://localhost:3061/', + integrations: process.env.E2E_TEST_TUNNEL ? [Sentry.serveTunnelIntegration()] : [], tracesSampleRate: 1.0, }); const port = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3060; -const server = http.createServer( - createRequestListener(request => { - // Handled outside the router on purpose: the only hook that can see this is the listener's - // `onError`, which is the case the router middleware cannot cover. - if (new URL(request.url).pathname === '/plain-throw') { - throw new Error('Plain handler failed'); +// The plain server variant serves the router without `createRequestListener`, so only the router middleware can +// answer the tunnel there. +const plainServer = !!process.env.E2E_TEST_PLAIN_SERVER; + +function handle(request: Request): Response | Promise { + // Handled outside the router on purpose: the only hook that can see this is the listener's + // `onError`, which is the case the router middleware cannot cover. + if (new URL(request.url).pathname === '/plain-throw') { + throw new Error('Plain handler failed'); + } + // The tunnel variant checks that the managed tunnel answers before code the app runs in front of its router. With + // the plain server nothing runs ahead of this, so it would block the tunnel there. + if ( + process.env.E2E_TEST_TUNNEL && + !plainServer && + request.method === 'POST' && + request.headers.get('x-app-auth') !== 'ok' + ) { + return new Response('blocked by the app before its router', { status: 403 }); + } + return router.fetch(request); +} + +/** + * A bare `node:http` adapter for the fetch handler. + */ +function toNodeListener(handler: (request: Request) => Response | Promise): http.RequestListener { + return async (req, res) => { + const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; + const request = new Request(`http://localhost:${port}${req.url}`, { + method: req.method, + headers: req.headers as HeadersInit, + body: hasBody ? (Readable.toWeb(req) as ReadableStream) : undefined, + duplex: 'half', + } as RequestInit); + + try { + const response = await handler(request); + res.writeHead(response.status, Object.fromEntries(response.headers)); + res.end(Buffer.from(await response.arrayBuffer())); + } catch { + res.writeHead(500).end(); } - return router.fetch(request); - }), -); + }; +} + +const server = http.createServer(plainServer ? toNodeListener(handle) : createRequestListener(handle)); server.listen(port, () => { console.log(`Server listening on http://localhost:${port}`); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts b/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts index 393a3e8e3195..5823184ba0c0 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts @@ -68,6 +68,37 @@ test('sends a navigation span for a link the runtime intercepts', async ({ page expect(span.attributes?.['sentry.segment.name.source']?.value).toBe('route'); }); +// The span is named by the navigation's own fetch, not the earlier prefetch. +for (const mode of ['intent', 'viewport'] as const) { + test(`names a navigation to a link prefetched on ${mode} by the route`, async ({ page }) => { + const prefetchPromise = page.waitForRequest( + request => request.url().endsWith(`/users/${mode}`) && request.headers()['x-remix-frame'] === 'true', + ); + + await page.goto('/'); + const link = page.locator(`#to-user-prefetch-${mode}`); + if (mode === 'intent') { + await link.hover(); + } + await (await prefetchPromise).response(); + + const spanPromise = waitForStreamedSpan( + APP_NAME, + span => + getSpanOp(span) === 'navigation' && + span.attributes?.['sentry.origin']?.value === 'auto.navigation.remix_v3' && + span.attributes?.['url.path']?.value === `/users/${mode}`, + ); + + await link.click(); + await expect(page.locator('#user')).toBeVisible(); + + const span = await spanPromise; + expect(span.name).toBe('/users/:id'); + expect(span.attributes?.['sentry.segment.name.source']?.value).toBe('route'); + }); +} + test('names a navigation to a route it loaded before right at the start', async ({ page }) => { await page.goto('/'); await page.locator('#to-user').click(); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/remix-v3/tests/tunnel.test.ts new file mode 100644 index 000000000000..6254b884e69a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/tests/tunnel.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from '@playwright/test'; +import { isServerSpanAt, watchForTunnelSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const TUNNEL_PATH = /^\/[a-z0-9]{1,7}$/; + +test('the app handler rejects other POSTs before the router', async ({ page }) => { + // The plain server has nothing ahead of the router that could answer the tunnel. + test.skip(!!process.env.E2E_TEST_PLAIN_SERVER, 'Only the request listener variant has a handler ahead of the router'); + + expect((await page.request.post('/anything', { data: 'x' })).status()).toBe(403); +}); + +test('answers the managed tunnel before the app handler, route matching and the app middleware', async ({ page }) => { + // The app's auth middleware rejects the POSTs that get past its handler. + expect((await page.request.post('/anything', { data: 'x', headers: { 'x-app-auth': 'ok' } })).status()).toBe(401); + + const raceTunnelSpan = watchForTunnelSpan('remix-v3', isServerSpanAt(TUNNEL_PATH)); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto('/'); + + expect((await tunnelResponsePromise).status()).toBe(200); + expect(await raceTunnelSpan(isServerSpanAt('/api/items/1'), () => page.request.get('/api/items/1'))).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/solid-static/package.json b/dev-packages/e2e-tests/test-applications/solid-static/package.json index 3449cfe057c5..8cf9909a537a 100644 --- a/dev-packages/e2e-tests/test-applications/solid-static/package.json +++ b/dev-packages/e2e-tests/test-applications/solid-static/package.json @@ -26,8 +26,5 @@ "dependencies": { "solid-js": "^1.8.18", "@sentry/solid": "file:../../packed/sentry-solid-packed.tgz" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/package.json b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/package.json index f518fec30661..860c5c299dbc 100644 --- a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/package.json +++ b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/package.json @@ -27,8 +27,5 @@ "typescript": "^5.7.2", "vite": "^7.3.2", "vite-plugin-solid": "^2.11.2" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/tests/routing-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/tests/routing-instrumentation.test.ts index 2e29eed35616..aea4a266822c 100644 --- a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/tests/routing-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/tests/routing-instrumentation.test.ts @@ -175,7 +175,7 @@ test('sends a pageload span with web vital attributes and a standalone LCP span' expect(lcpSpan.attributes).toMatchObject({ 'sentry.op': { value: 'ui.webvital.lcp', type: 'string' }, - 'sentry.origin': { value: 'auto.http.browser.lcp', type: 'string' }, + 'sentry.origin': { value: 'auto.ui.browser.lcp', type: 'string' }, 'sentry.pageload.span_id': { value: pageloadSpan.span_id, type: 'string' }, 'browser.web_vital.lcp.value': webVitalNumber, }); diff --git a/dev-packages/e2e-tests/test-applications/solid/package.json b/dev-packages/e2e-tests/test-applications/solid/package.json index 7d9d35b3afa3..d3224e8db4bd 100644 --- a/dev-packages/e2e-tests/test-applications/solid/package.json +++ b/dev-packages/e2e-tests/test-applications/solid/package.json @@ -26,8 +26,5 @@ "dependencies": { "solid-js": "^1.8.18", "@sentry/solid": "file:../../packed/sentry-solid-packed.tgz" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/assert-build.ts b/dev-packages/e2e-tests/test-applications/solidstart-2/assert-build.ts new file mode 100644 index 000000000000..9cf7afcbe4ec --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/assert-build.ts @@ -0,0 +1,24 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { assertSourceMapUpload, findDebugIdsInFilesWithGlobCharacters } from '@sentry-internal/test-utils'; + +const CLIENT_OUTPUT = path.join('.output', 'public'); +const SERVER_OUTPUT = path.join('.output', 'server'); + +// The app does not set `build.sourcemap`, so the SDK enables hidden maps and must delete them after upload. +// TODO: The client maps stay in `.output/public`, because the SDK's deletion glob `./**/*.map` skips dot +// folders. Add `CLIENT_OUTPUT` to `strippedDirs` once the SDK deletes them. +assertSourceMapUpload({ + outputDirs: [CLIENT_OUTPUT, SERVER_OUTPUT], + strippedDirs: [SERVER_OUTPUT], + markers: { 'client source': 'SOURCEMAP_MARKER_CLIENT', 'server source': 'SOURCEMAP_MARKER_SERVER' }, + debugIdsWithoutSourceMap: [ + // Written by the build, see `recordChunksWithoutSourceMap` in `vite.config.ts`. + ...(fs.existsSync('.tmp_debug_ids_without_source_map') + ? fs.readFileSync('.tmp_debug_ids_without_source_map', 'utf8').split('\n').filter(Boolean) + : []), + ...findDebugIdsInFilesWithGlobCharacters({ outputDir: SERVER_OUTPUT }), + ], +}); + +console.log('All sourcemap assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/package.json b/dev-packages/e2e-tests/test-applications/solidstart-2/package.json index 376bb2ef38fc..a8336229c229 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/package.json @@ -4,12 +4,12 @@ "//": "Sentry is initialized from server/plugins/sentry.ts, a Nitro plugin. Build-time instrumentation means there is no `--import` preload and nothing to copy into .output.", "scripts": { "clean": "pnpx rimraf node_modules pnpm-lock.yaml .nitro .output", - "build": "vite build", + "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", "preview": "HOST=localhost PORT=3030 vite preview", "start": "HOST=localhost PORT=3030 node .output/server/index.mjs", "test:prod": "TEST_ENV=production playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test:prod" + "test:assert": "node assert-build.ts && pnpm test:prod" }, "type": "module", "dependencies": { @@ -31,9 +31,6 @@ "vite": "^8.1.5", "vite-plugin-solid": "^2.11.6" }, - "volta": { - "extends": "../../package.json" - }, "engines": { "node": ">=24" }, diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-client.tsx b/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-client.tsx index 11087fbb5918..17e38cf08b72 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-client.tsx +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-client.tsx @@ -1,3 +1,4 @@ +// SOURCEMAP_MARKER_CLIENT // @refresh reload import * as Sentry from '@sentry/solidstart'; import { solidRouterBrowserTracingIntegration } from '@sentry/solidstart/solidrouter'; diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-server.tsx b/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-server.tsx index 276935366318..78f28e36a56c 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-server.tsx +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-server.tsx @@ -1,3 +1,4 @@ +// SOURCEMAP_MARKER_SERVER // @refresh reload import { StartServer, createHandler } from '@solidjs/start/server'; diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/start-mock-sentry-server.mjs b/dev-packages/e2e-tests/test-applications/solidstart-2/start-mock-sentry-server.mjs new file mode 100644 index 000000000000..cce37a5f9daf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/start-mock-sentry-server.mjs @@ -0,0 +1,3 @@ +import { startMockSentryServer } from '@sentry-internal/test-utils'; + +startMockSentryServer(); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/vite.config.ts b/dev-packages/e2e-tests/test-applications/solidstart-2/vite.config.ts index d3ffd97997a9..74811201d2cf 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/vite.config.ts @@ -2,6 +2,27 @@ import { sentrySolidStart } from '@sentry/solidstart/vite'; import { solidStart } from '@solidjs/start/config'; import { nitro } from 'nitro/vite'; import { defineConfig } from 'vite'; +import * as fs from 'node:fs'; + +// Rolldown emits no map for chunks without mappable source (e.g. Vite's preload helper), so their debug +// IDs can't be uploaded. Recorded here because the SDK deletes all maps after the upload. +function recordChunksWithoutSourceMap() { + return { + name: 'record-chunks-without-source-map', + apply: 'build' as const, + enforce: 'post' as const, + writeBundle(_options: unknown, bundle: Record) { + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk' || !output.code || bundle[`${output.fileName}.map`]) { + continue; + } + for (const match of output.code.matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { + fs.appendFileSync('.tmp_debug_ids_without_source_map', `${match[1]!.toLowerCase()}\n`); + } + } + }, + }; +} export default defineConfig({ plugins: [ @@ -10,12 +31,15 @@ export default defineConfig({ middleware: './src/middleware.ts', }), sentrySolidStart({ - org: process.env.E2E_TEST_SENTRY_ORG_SLUG, - project: process.env.E2E_TEST_SENTRY_PROJECT, - authToken: process.env.E2E_TEST_AUTH_TOKEN, + sentryUrl: 'http://localhost:3032', + authToken: 'fake-auth-token', + org: 'test-org', + project: 'test-project', + release: { name: 'test-release' }, debug: true, }), // `serverDir` defaults to `false`, which skips plugin scanning entirely. nitro({ serverDir: './server' }), + recordChunksWithoutSourceMap(), ], }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/package.json b/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/package.json index 7d4df305aadc..7dfcebfffef2 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/package.json @@ -29,8 +29,5 @@ "vinxi": "^0.4.0", "vite": "^5.4.10", "vite-plugin-solid": "^2.11.6" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/solidstart-spa/package.json b/dev-packages/e2e-tests/test-applications/solidstart-spa/package.json index 417ec5c26a56..e8f890d6434d 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-spa/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-spa/package.json @@ -29,8 +29,5 @@ "vinxi": "^0.4.0", "vite": "^5.4.11", "vite-plugin-solid": "^2.11.6" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/solidstart-static/package.json b/dev-packages/e2e-tests/test-applications/solidstart-static/package.json index b3ff8de5660a..38ddeac40f07 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-static/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-static/package.json @@ -18,7 +18,7 @@ }, "devDependencies": { "@playwright/test": "1.64.1", - "@sentry/conventions": "0.27.0", + "@sentry/conventions": "0.29.0", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "@solidjs/meta": "^0.29.4", "@solidjs/router": "^1.0.0", @@ -32,8 +32,5 @@ "vinxi": "^0.4.0", "vite": "^5.4.11", "vite-plugin-solid": "^2.11.6" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/package.json b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/package.json index 660dfe575fa6..2b266bfdeb9b 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/package.json @@ -29,8 +29,5 @@ "vinxi": "^0.4.0", "vite": "^5.4.11", "vite-plugin-solid": "^2.11.6" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts b/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts index 71061cf25d96..be3bce2116a7 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts @@ -2,7 +2,24 @@ import { withSentry } from '@sentry/solidstart'; import { defineConfig } from '@solidjs/start/config'; export default defineConfig( - withSentry({ - middleware: './src/middleware.ts', - }), + withSentry( + { + middleware: './src/middleware.ts', + // The base-path variant serves the app, and so the tunnel, under `/app`. + ...(process.env.E2E_TEST_BASE_PATH && { server: { baseURL: `${process.env.E2E_TEST_BASE_PATH}/` } }), + vite: { + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + }, + }, + }, + { + 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/solidstart/assert-build.ts b/dev-packages/e2e-tests/test-applications/solidstart/assert-build.ts new file mode 100644 index 000000000000..fd40cfc4de2a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart/assert-build.ts @@ -0,0 +1,16 @@ +import * as path from 'node:path'; +import { assertSourceMapUpload } from '@sentry-internal/test-utils'; + +const CLIENT_OUTPUT = path.join('.output', 'public'); +const SERVER_OUTPUT = path.join('.output', 'server'); + +// The app does not set `build.sourcemap`, so the SDK enables hidden maps and must delete them after upload. +// Vinxi writes the server output after the SDK deleted the maps, so `.output/server` keeps Nitro's own maps, +// which carry no mappings. They are not served, so only the client output must be free of maps. +assertSourceMapUpload({ + outputDirs: [CLIENT_OUTPUT, SERVER_OUTPUT], + strippedDirs: [CLIENT_OUTPUT], + markers: { 'client source': 'SOURCEMAP_MARKER_CLIENT', 'server source': 'SOURCEMAP_MARKER_SERVER' }, +}); + +console.log('All sourcemap assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/instrument.server.mjs b/dev-packages/e2e-tests/test-applications/solidstart/instrument.server.mjs index f75de066068a..e0ff51dad59d 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/instrument.server.mjs +++ b/dev-packages/e2e-tests/test-applications/solidstart/instrument.server.mjs @@ -7,5 +7,6 @@ Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, // Capture 100% of the transactions tunnel: 'http://localhost:3031/', // proxy server + integrations: process.env.E2E_TEST_TUNNEL ? [Sentry.serveTunnelIntegration()] : [], debug: !!process.env.DEBUG, }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/package.json b/dev-packages/e2e-tests/test-applications/solidstart/package.json index 5567434a3c0e..daceab6d5f10 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart/package.json @@ -3,14 +3,17 @@ "version": "0.0.0", "scripts": { "clean": "pnpx rimraf node_modules pnpm-lock.yaml .vinxi .output", - "build": "vinxi build", + "build": "node start-mock-sentry-server.mjs & vinxi 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", "dev": "vinxi dev", "preview": "HOST=localhost PORT=3030 vinxi start", "start:import": "HOST=localhost PORT=3030 node --import ./.output/server/instrument.server.mjs .output/server/index.mjs", "test:prod": "TEST_ENV=production playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test:prod && pnpm test:dev", - "test:dev": "TEST_ENV=development playwright test db" + "test:assert": "node assert-build.ts && pnpm test:prod && pnpm test:dev", + "test:dev": "TEST_ENV=development playwright test db", + "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm build", + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-basepath-dev": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=development playwright test tunnel" }, "type": "module", "dependencies": { @@ -30,11 +33,22 @@ "jsdom": "^24.0.0", "solid-js": "1.9.5", "typescript": "^5.4.5", - "vinxi": "^0.4.0", - "vite": "^5.4.11", + "vinxi": "^0.5.11", + "vite": "^6.4.4", "vite-plugin-solid": "^2.11.6" }, - "volta": { - "extends": "../../package.json" + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath", + "label": "solidstart (tunnel-basepath)" + }, + { + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-basepath-dev", + "label": "solidstart (tunnel-basepath-dev)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/solidstart/src/entry-client.tsx b/dev-packages/e2e-tests/test-applications/solidstart/src/entry-client.tsx index 11087fbb5918..e4413bd7e137 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/src/entry-client.tsx +++ b/dev-packages/e2e-tests/test-applications/solidstart/src/entry-client.tsx @@ -1,15 +1,18 @@ +// SOURCEMAP_MARKER_CLIENT // @refresh reload import * as Sentry from '@sentry/solidstart'; import { solidRouterBrowserTracingIntegration } from '@sentry/solidstart/solidrouter'; import { StartClient, mount } from '@solidjs/start/client'; +declare const __E2E_TEST_TUNNEL__: boolean; + Sentry.init({ // We can't use env variables here, seems like they are stripped // out in production builds. - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: __E2E_TEST_TUNNEL__ ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', environment: 'qa', // dynamic sampling bias to keep transactions integrations: [solidRouterBrowserTracingIntegration()], - tunnel: 'http://localhost:3031/', // proxy server + tunnel: __E2E_TEST_TUNNEL__ ? true : 'http://localhost:3031/', // proxy server // Performance Monitoring tracesSampleRate: 1.0, // Capture 100% of the transactions debug: !!import.meta.env.DEBUG, diff --git a/dev-packages/e2e-tests/test-applications/solidstart/src/entry-server.tsx b/dev-packages/e2e-tests/test-applications/solidstart/src/entry-server.tsx index 276935366318..78f28e36a56c 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/src/entry-server.tsx +++ b/dev-packages/e2e-tests/test-applications/solidstart/src/entry-server.tsx @@ -1,3 +1,4 @@ +// SOURCEMAP_MARKER_SERVER // @refresh reload import { StartServer, createHandler } from '@solidjs/start/server'; diff --git a/dev-packages/e2e-tests/test-applications/solidstart/src/instrument.server.ts b/dev-packages/e2e-tests/test-applications/solidstart/src/instrument.server.ts index 3dd5d8933b7b..98ac2560613a 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/src/instrument.server.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart/src/instrument.server.ts @@ -5,5 +5,6 @@ Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, // Capture 100% of the transactions tunnel: 'http://localhost:3031/', // proxy server + integrations: process.env.E2E_TEST_TUNNEL ? [Sentry.serveTunnelIntegration()] : [], debug: !!process.env.DEBUG, }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/src/middleware.ts b/dev-packages/e2e-tests/test-applications/solidstart/src/middleware.ts index 88123a035fb6..9dee5a5d3eb8 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/src/middleware.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart/src/middleware.ts @@ -2,5 +2,14 @@ import { sentryBeforeResponseMiddleware } from '@sentry/solidstart'; import { createMiddleware } from '@solidjs/start/middleware'; export default createMiddleware({ + onRequest: [ + // The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. + event => { + const request = event.request; + if (process.env.E2E_TEST_TUNNEL && request.method === 'POST' && request.headers.get('x-auth') !== 'ok') { + return new Response('blocked by user auth', { status: 401 }); + } + }, + ], onBeforeResponse: [sentryBeforeResponseMiddleware()], }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/start-mock-sentry-server.mjs b/dev-packages/e2e-tests/test-applications/solidstart/start-mock-sentry-server.mjs new file mode 100644 index 000000000000..d4bca7c2c259 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart/start-mock-sentry-server.mjs @@ -0,0 +1,4 @@ +import { startMockSentryServer } from '@sentry-internal/test-utils'; + +// Not a dot-folder: the SDK deletes `.*/**/*.map`, which would remove the maps the server extracts here. +startMockSentryServer({ outputDir: 'tmp_mock_chunks' }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/solidstart/tests/tunnel.test.ts new file mode 100644 index 000000000000..adf43c0d4f0c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart/tests/tunnel.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from '@playwright/test'; +import { isServerSpanAt, watchForTunnelSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const APP_NAME = 'solidstart'; +const BASE_PATH = process.env.E2E_TEST_BASE_PATH ?? ''; +const TUNNEL_PATH = new RegExp(`^${BASE_PATH}/[a-z0-9]{1,7}$`); + +test('answers the managed tunnel before the app middleware', async ({ page }) => { + // The variant's auth middleware rejects every other POST. + expect((await page.request.post(`${BASE_PATH}/`, { data: 'x' })).status()).toBe(401); + + const raceTunnelSpan = watchForTunnelSpan(APP_NAME, isServerSpanAt(TUNNEL_PATH)); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto(`${BASE_PATH}/`); + + expect((await tunnelResponsePromise).status()).toBe(200); + expect( + await raceTunnelSpan(isServerSpanAt(`${BASE_PATH}/users/6`), () => page.request.get(`${BASE_PATH}/users/6`)), + ).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json b/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json index 7fc3d45d4a08..ba96bf446930 100644 --- a/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json @@ -7,7 +7,7 @@ "build": "next build", "start": "next start", "clean": "npx rimraf node_modules pnpm-lock.yaml .next", - "start-local-supabase": "supabase stop --no-backup 2>/dev/null || true && supabase start -o env", + "start-local-supabase": "supabase stop --no-backup 2>/dev/null || true && SUPABASE_INTERNAL_IMAGE_REGISTRY=ghcr.io supabase start -x postgres-meta -o env", "test:prod": "TEST_ENV=production playwright test", "test:build": "pnpm install && pnpm start-local-supabase && pnpm build", "test:assert": "pnpm test:prod" @@ -34,9 +34,6 @@ "eslint": "8.34.0", "eslint-config-next": "14.2.25" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "optional": true } diff --git a/dev-packages/e2e-tests/test-applications/svelte-5-static/package.json b/dev-packages/e2e-tests/test-applications/svelte-5-static/package.json index 23930326addc..02c83d3b83ad 100644 --- a/dev-packages/e2e-tests/test-applications/svelte-5-static/package.json +++ b/dev-packages/e2e-tests/test-applications/svelte-5-static/package.json @@ -25,8 +25,5 @@ }, "dependencies": { "@sentry/svelte": "file:../../packed/sentry-svelte-packed.tgz" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/svelte-5/package.json b/dev-packages/e2e-tests/test-applications/svelte-5/package.json index 25013bcc894b..98d6261ce47b 100644 --- a/dev-packages/e2e-tests/test-applications/svelte-5/package.json +++ b/dev-packages/e2e-tests/test-applications/svelte-5/package.json @@ -25,8 +25,5 @@ }, "dependencies": { "@sentry/svelte": "file:../../packed/sentry-svelte-packed.tgz" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/package.json index 3d00ca2b2b3b..c3b4ce462190 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/package.json @@ -30,8 +30,5 @@ "typescript": "^5.0.0", "vite": "^7.3.2" }, - "type": "module", - "volta": { - "extends": "../../package.json" - } + "type": "module" } diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/package.json index d82441f2e072..cc17b79e7d20 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/package.json @@ -35,8 +35,5 @@ "typescript": "^5.0.0", "vite": "^7.3.2" }, - "type": "module", - "volta": { - "extends": "../../package.json" - } + "type": "module" } diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/package.json index 8012a82462df..f9a220037668 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/package.json @@ -36,8 +36,5 @@ "typescript": "^5.0.0", "vite": "^5.4.11" }, - "volta": { - "extends": "../../package.json" - }, "type": "module" } diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/package.json index eb4e10fd26e4..45b5405a3433 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/package.json @@ -30,8 +30,5 @@ "typescript": "^5.0.0", "vite": "^5.4.11" }, - "type": "module", - "volta": { - "extends": "../../package.json" - } + "type": "module" } diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2.5.0-twp/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-2.5.0-twp/package.json index 0677dd30e040..18092c2dfef8 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2.5.0-twp/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2.5.0-twp/package.json @@ -29,8 +29,5 @@ "typescript": "^5.0.0", "vite": "^5.4.11" }, - "type": "module", - "volta": { - "extends": "../../package.json" - } + "type": "module" } diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-2/package.json index 86fa6669a880..1ca6fd124a5e 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/package.json @@ -36,8 +36,5 @@ "typescript": "^5.0.0", "vite": "^5.4.11" }, - "volta": { - "extends": "../../package.json" - }, "type": "module" } diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/package.json index 44cfcdf782d8..62446e94a44f 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/package.json @@ -11,7 +11,9 @@ "clean": "npx rimraf node_modules pnpm-lock.yaml", "test:e2e": "playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test:e2e" + "test:assert": "pnpm test:e2e", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 playwright test tunnel" }, "dependencies": { "@sentry/sveltekit": "file:../../packed/sentry-sveltekit-packed.tgz" @@ -28,7 +30,13 @@ "vite": "^8.0.12", "wrangler": "^4.118.0" }, - "volta": { - "extends": "../../package.json" + "sentryTest": { + "variants": [ + { + "label": "sveltekit-3-cloudflare-workers (tunnel)", + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/env.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/env.ts index d7490e9d661c..9e2fefad4bdb 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/env.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/env.ts @@ -8,4 +8,7 @@ export const variables = defineEnvVars({ // `process.env` at runtime, so a dynamic var would never reach the SDK. E2E_TEST_DSN: { static: true }, PUBLIC_E2E_TEST_DSN: { public: true, static: true }, + // Only the tunnel variant sets these, so a missing value means the tunnel is off. + E2E_TEST_TUNNEL: { static: true, schema: (value: string | undefined) => !!value }, + PUBLIC_E2E_TEST_TUNNEL: { public: true, static: true, schema: (value: string | undefined) => !!value }, }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.client.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.client.ts index ba1d3dfa2be0..91da68b1662e 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.client.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.client.ts @@ -1,10 +1,10 @@ -import { PUBLIC_E2E_TEST_DSN } from '$app/env/public'; +import { PUBLIC_E2E_TEST_DSN, PUBLIC_E2E_TEST_TUNNEL } from '$app/env/public'; import * as Sentry from '@sentry/sveltekit'; Sentry.init({ environment: 'qa', dsn: PUBLIC_E2E_TEST_DSN, - tunnel: 'http://localhost:3031/', // proxy server + tunnel: PUBLIC_E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.server.ts index 1b8dd4528c36..ddc08acc1af6 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.server.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.server.ts @@ -1,5 +1,10 @@ -import { E2E_TEST_DSN } from '$app/env/private'; -import { handleErrorWithSentry, initCloudflareSentryHandle, sentryHandle } from '@sentry/sveltekit'; +import { E2E_TEST_DSN, E2E_TEST_TUNNEL } from '$app/env/private'; +import { + handleErrorWithSentry, + initCloudflareSentryHandle, + sentryHandle, + serveTunnelIntegration, +} from '@sentry/sveltekit'; import { sequence } from '@sveltejs/kit/hooks'; // not logging anything to console to avoid noise in the test output @@ -11,6 +16,7 @@ export const handle = sequence( dsn: E2E_TEST_DSN, debug: !!process.env.DEBUG, tunnel: 'http://localhost:3031/', // proxy server + integrations: E2E_TEST_TUNNEL ? [serveTunnelIntegration()] : [], tracesSampleRate: 1.0, }), sentryHandle(), diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/tests/tunnel.test.ts new file mode 100644 index 000000000000..5888b32afaa8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/tests/tunnel.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, isServerSpanAt, waitForStreamedSpan, watchForTunnelSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const APP_NAME = 'sveltekit-3-cloudflare-workers'; +const TUNNEL_PATH = /^\/app\/[a-z0-9]{1,7}$/; + +test('serves the managed tunnel from the Worker under the base path', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan(APP_NAME, span => getSpanOp(span) === 'pageload' && span.is_segment); + const raceTunnelSpan = watchForTunnelSpan(APP_NAME, isServerSpanAt(TUNNEL_PATH)); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto('/app/'); + + expect((await tunnelResponsePromise).status()).toBe(200); + await expect(pageloadSpanPromise).resolves.toBeDefined(); + expect( + await raceTunnelSpan(isServerSpanAt('/app/server-load-error'), () => page.request.get('/app/server-load-error')), + ).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/vite.config.js b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/vite.config.js index f83d55aa81e8..7c3fd99a4d86 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/vite.config.js +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/vite.config.js @@ -4,6 +4,9 @@ import { sveltekit } from '@sveltejs/kit/vite'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { defineConfig } from 'vite'; +// The tunnel variant serves the app under a base path to cover that the managed tunnel follows it. +const tunnelVariant = !!process.env.E2E_TEST_TUNNEL; + export default defineConfig({ plugins: [ sentrySvelteKit({ @@ -17,6 +20,7 @@ export default defineConfig({ prerender: { handleHttpError: 'ignore', }, + paths: tunnelVariant ? { base: '/app' } : undefined, }), ], }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/assert-build.ts index 697c4437532d..7f70c46b9941 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/assert-build.ts @@ -1,74 +1,29 @@ import * as assert from 'node:assert/strict'; -import * as fs from 'node:fs'; import * as path from 'node:path'; -import { - findInjectedDebugIds, - findSourceMapFiles, - findSourceMappingUrlComments, - getArtifactBundles, - getAssembleRequests, - getChunkUploadPosts, - getDebugIdPairs, - getSourcemaps, - loadMockServerResults, -} from '@sentry-internal/test-utils'; +import { assertSourceMapUpload, findSourceMapFiles } from '@sentry-internal/test-utils'; const BUILD_OUTPUT = 'build'; const CLIENT_OUTPUT = path.join(BUILD_OUTPUT, 'client'); const SERVER_OUTPUT = path.join(BUILD_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 { requests, sourcemaps } = assertSourceMapUpload({ + outputDirs: [CLIENT_OUTPUT, SERVER_OUTPUT], + // The server output keeps `sourceMappingURL` comments that point at the deleted maps. Nothing serves + // it, so only the client output must be free of them. + strippedDirs: [CLIENT_OUTPUT], + markers: { 'client source': 'SOURCEMAP_MARKER_CLIENT', 'server source': 'SOURCEMAP_MARKER_SERVER' }, + // Written by the build, see `recordChunksWithoutSourceMap` in `vite.config.js`. + debugIdsWithoutSourceMap: '.tmp_debug_ids_without_source_map', +}); -/** Written by the build, see `recordChunksWithoutSourceMap` in `vite.config.js`. */ -const debugIdsWithoutSourceMap = new Set( - fs.existsSync('.tmp_debug_ids_without_source_map') - ? fs.readFileSync('.tmp_debug_ids_without_source_map', 'utf8').split('\n').filter(Boolean) - : [], -); - -const requests = loadMockServerResults(); - -console.log(`Captured ${requests.length} requests to mock Sentry server:\n`); -for (const request of requests) { - console.log(` ${request.method} ${request.url} (${request.bodySize} bytes)`); -} -console.log(''); - -assert.ok( - requests.some(r => r.authorization.includes('fake-auth-token')), - 'Expected requests with the configured auth token', -); +// The app does not set `build.sourcemap`, so the SDK enables hidden maps and must delete them after upload. +assert.deepEqual(findSourceMapFiles({ outputDir: BUILD_OUTPUT }), [], `Expected no source maps in ${BUILD_OUTPUT}`); assert.ok( requests.some(r => r.url?.includes('/releases') && r.method === 'POST'), 'Expected a POST to create the release', ); -assert.ok( - getChunkUploadPosts(requests).some(r => r.bodySize > 0), - 'Expected at least one chunk upload POST with a non-empty body', -); - -const assembleRequests = getAssembleRequests(requests); -assert.ok(assembleRequests.length > 0, 'Expected at least one assemble request'); -for (const request of assembleRequests) { - assert.ok(request.assembleBody?.projects?.includes('test-project'), 'Expected assemble request for test-project'); - assert.equal(request.assembleBody?.version, 'test-release', 'Expected assemble request to reference the release'); -} - -const bundles = getArtifactBundles(requests); -assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest'); - -const sourcemaps = getSourcemaps(bundles); - -const containsMarker = (marker: string): boolean => - sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))); - -assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded sourcemap carrying the client source'); -assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded sourcemap carrying the server source'); - // The auto-instrument plugin loads wrapped modules under this suffix. It must be stripped before // upload, or the uploaded sources don't match the frames Sentry receives. const wrappedSources = sourcemaps @@ -76,25 +31,4 @@ const wrappedSources = sourcemaps .filter(source => source.includes('?sentry-auto-wrap')); assert.deepEqual(wrappedSources, [], 'Expected no uploaded source to carry the auto-wrap query suffix'); -const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase())); -assert.ok(uploadedDebugIds.size > 0, 'Expected at least one JS/sourcemap pair with matching debug IDs'); - -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 unuploaded = injectedDebugIds.filter( - debugId => !uploadedDebugIds.has(debugId) && !debugIdsWithoutSourceMap.has(debugId), - ); - assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded sourcemap`); -} - -// The app does not set `build.sourcemap`, so the SDK enables hidden maps and must delete them after upload. -assert.deepEqual(findSourceMapFiles({ outputDir: BUILD_OUTPUT }), [], `Expected no source maps in ${BUILD_OUTPUT}`); -assert.deepEqual( - findSourceMappingUrlComments({ outputDir: CLIENT_OUTPUT }), - [], - `Expected no sourceMappingURL comments in ${CLIENT_OUTPUT}`, -); - console.log('All sourcemap assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/package.json index 089139dcb68f..2605575c9cb8 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/package.json @@ -22,8 +22,5 @@ "svelte": "^5.57.1", "typescript": "^6.0.0", "vite": "^8.0.12" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json index 214b7c082def..32ccc8e5fffd 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json @@ -12,7 +12,10 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "test:prod": "TEST_ENV=production playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test:prod" + "test:assert": "pnpm test:prod", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-dev": "E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=development playwright test tunnel" }, "dependencies": { "@sentry/sveltekit": "file:../../packed/sentry-sveltekit-packed.tgz" @@ -28,8 +31,19 @@ "typescript": "^6.0.0", "vite": "^8.0.12" }, - "volta": { - "extends": "../../package.json" - }, - "type": "module" + "type": "module", + "sentryTest": { + "variants": [ + { + "label": "sveltekit-3 (tunnel)", + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel" + }, + { + "label": "sveltekit-3 (tunnel-dev)", + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev" + } + ] + } } diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/env.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/env.ts index ce7e354ed8b6..91d4b6acbe51 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/env.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/env.ts @@ -10,4 +10,7 @@ export const variables = defineEnvVars({ // `process.env` — so the DSN would never reach the server SDK. E2E_TEST_DSN: { static: true }, PUBLIC_E2E_TEST_DSN: { public: true }, + // Only the tunnel variant sets these, so a missing value means the tunnel is off. + E2E_TEST_TUNNEL: { static: true, schema: (value: string | undefined) => !!value }, + PUBLIC_E2E_TEST_TUNNEL: { public: true, static: true, schema: (value: string | undefined) => !!value }, }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/hooks.client.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/hooks.client.ts index 4d4723a527d3..70717a551174 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/hooks.client.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/hooks.client.ts @@ -1,10 +1,10 @@ -import { PUBLIC_E2E_TEST_DSN } from '$app/env/public'; +import { PUBLIC_E2E_TEST_DSN, PUBLIC_E2E_TEST_TUNNEL } from '$app/env/public'; import * as Sentry from '@sentry/sveltekit'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: PUBLIC_E2E_TEST_DSN, - tunnel: `http://localhost:3031/`, // proxy server + tunnel: PUBLIC_E2E_TEST_TUNNEL ? true : `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/instrumentation.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/instrumentation.server.ts index 18c0b65b6db7..b048d94e5bf5 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/instrumentation.server.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/instrumentation.server.ts @@ -1,4 +1,4 @@ -import { E2E_TEST_DSN } from '$app/env/private'; +import { E2E_TEST_DSN, E2E_TEST_TUNNEL } from '$app/env/private'; import * as Sentry from '@sentry/sveltekit'; // With SvelteKit 3 native instrumentation enabled (`experimental.instrumentation.server`), @@ -8,5 +8,6 @@ Sentry.init({ dsn: E2E_TEST_DSN, debug: !!process.env.DEBUG, tunnel: `http://localhost:3031/`, // proxy server + integrations: E2E_TEST_TUNNEL ? [Sentry.serveTunnelIntegration()] : [], tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tunnel.test.ts new file mode 100644 index 000000000000..55f5a54298ff --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tunnel.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, watchForTunnelSpan } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const APP_NAME = 'sveltekit-3'; +const TUNNEL_PATH = /^\/app\/[a-z0-9]{1,7}$/; + +// Kit's own `sveltekit.handle.root` span starts with only `http.url`, no `url.path`. +function getSpanPath(span: SerializedStreamedSpan): string { + const url = String(span.attributes['http.url']?.value ?? span.attributes['url.path']?.value ?? ''); + + return url.startsWith('http') ? new URL(url).pathname : url; +} + +test('sends client events through the managed tunnel under the base path, without spans of it', async ({ page }) => { + const errorEventPromise = waitForError(APP_NAME, event => event?.exception?.values?.[0]?.value === 'Click Error'); + const raceTunnelSpan = watchForTunnelSpan(APP_NAME, span => TUNNEL_PATH.test(getSpanPath(span))); + + // The pageload envelope is the first tunnel request, and it's only sent once the app hydrated. + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + await page.goto('/app/client-error'); + + expect((await tunnelResponsePromise).status()).toBe(200); + + await page.getByText('Throw error').click(); + await expect(errorEventPromise).resolves.toBeDefined(); + + expect( + await raceTunnelSpan( + span => getSpanPath(span).endsWith('/app/users/123'), + () => page.request.get('/app/users/123'), + ), + ).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/vite.config.js b/dev-packages/e2e-tests/test-applications/sveltekit-3/vite.config.js index 3e789da133e5..da4994e9e6aa 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/vite.config.js +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/vite.config.js @@ -4,6 +4,9 @@ import { sveltekit } from '@sveltejs/kit/vite'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { defineConfig } from 'vite'; +// The tunnel variant serves the app under a base path to cover that the managed tunnel follows it. +const tunnelVariant = !!process.env.E2E_TEST_TUNNEL; + export default defineConfig({ plugins: [ sentrySvelteKit({ @@ -22,6 +25,7 @@ export default defineConfig({ tracing: { server: true, }, + paths: tunnelVariant ? { base: '/app' } : undefined, }), ], }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/package.json index 1be8a714f3fa..71d69c4b7c2f 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/package.json @@ -29,8 +29,5 @@ "typescript": "^5.0.0", "vite": "^6.4.2", "wrangler": "^4.61.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/tanstack-router/package.json b/dev-packages/e2e-tests/test-applications/tanstack-router/package.json index 31d436caf4e9..b7599df88450 100644 --- a/dev-packages/e2e-tests/test-applications/tanstack-router/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstack-router/package.json @@ -30,9 +30,6 @@ "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "variants": [ { diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/package.json index f4e282ce3c4c..f140304221ec 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/package.json @@ -34,9 +34,6 @@ "vite-tsconfig-paths": "^6.1.1", "wrangler": "^4.68.1" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "optional": true } diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/server.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/server.ts index b35026939d88..1d2f09c0b10c 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/server.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/server.ts @@ -2,6 +2,8 @@ import * as Sentry from '@sentry/cloudflare'; import { wrapFetchWithSentry } from '@sentry/tanstackstart-react'; import handler from '@tanstack/react-start/server-entry'; +const app = wrapFetchWithSentry(handler); + export default Sentry.withSentry( (env: Env) => ({ dsn: env.E2E_TEST_DSN, @@ -9,5 +11,14 @@ export default Sentry.withSentry( tracesSampleRate: 1.0, environment: 'qa', }), - wrapFetchWithSentry(handler), + { + fetch(request, env, ctx) { + // Lets a test check that the Worker's `ctx` survives the Sentry Vite plugin wrapping the server entry. + if (new URL(request.url).pathname === '/test-execution-context') { + return new Response(typeof ctx?.waitUntil); + } + + return (app.fetch as ExportedHandlerFetchHandler)(request, env, ctx); + }, + } satisfies ExportedHandler, ); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/tunnel.test.ts new file mode 100644 index 000000000000..cb96639c8bf5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/tunnel.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from '@playwright/test'; + +test("keeps the Worker's ExecutionContext when the Sentry Vite plugin wraps the server entry", async ({ request }) => { + const response = await request.get('/test-execution-context'); + + expect(await response.text()).toBe('function'); +}); + +test('answers a fixed tunnelRoute with the DSN that `withSentry` initializes', async ({ request }) => { + // A DSN the server doesn't allow, so the tunnel answers without forwarding anywhere. + const envelopeHeader = { dsn: 'https://other@o1.ingest.sentry.io/42', sent_at: new Date().toISOString() }; + const response = await request.post('/monitoring', { + data: `${JSON.stringify(envelopeHeader)}\n`, + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }); + + expect(response.status()).toBe(403); +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/vite.config.ts index 8e749133c7d8..b9657a989669 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/vite.config.ts @@ -3,10 +3,17 @@ import tsConfigPaths from 'vite-tsconfig-paths'; import { tanstackStart } from '@tanstack/react-start/plugin/vite'; import viteReact from '@vitejs/plugin-react'; import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite'; export default defineConfig({ server: { port: 3030, }, - plugins: [cloudflare({ viteEnvironment: { name: 'ssr' } }), tsConfigPaths(), tanstackStart(), viteReact()], + plugins: [ + cloudflare({ viteEnvironment: { name: 'ssr' } }), + tsConfigPaths(), + tanstackStart(), + viteReact(), + sentryTanstackStart({ tunnelRoute: '/monitoring', sourcemaps: { disable: true } }), + ], }); 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..1dd5050c0372 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json @@ -0,0 +1,34 @@ +{ + "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" + }, + "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-static/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/package.json index 71d2451351bc..e14277b3ff56 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/package.json @@ -45,8 +45,5 @@ "vite-tsconfig-paths": "^5.1.4", "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/tunnel.test.ts index 76a39f44be81..966441835367 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/tunnel.test.ts @@ -10,7 +10,7 @@ const expectedTunnelPathMatcher = ? '/custom-monitor' : tunnelRouteMode === 'object' ? '/object-monitor' - : /^\/[a-z0-9]{8}$/; + : /^\/[a-z0-9]{1,7}$/; test.skip(tunnelRouteMode === 'off', 'Tunnel assertions only run in the tunnel-route variants'); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/assert-build.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/assert-build.ts new file mode 100644 index 000000000000..422a947f537a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/assert-build.ts @@ -0,0 +1,16 @@ +import * as path from 'node:path'; +import { assertSourceMapUpload } from '@sentry-internal/test-utils'; + +const CLIENT_OUTPUT = path.join('.output', 'public'); + +// The app does not set `build.sourcemap`, so the SDK enables hidden maps and must delete them after upload. +// The SDK enables them for the client build only: the server build in `.output/server` ships debug IDs but +// no source maps, so nothing of it reaches Sentry. Cover the server here once the SDK uploads its maps. +assertSourceMapUpload({ + outputDirs: [CLIENT_OUTPUT], + markers: { 'client source': 'SOURCEMAP_MARKER_CLIENT' }, + // Written by the build, see `recordChunksWithoutSourceMap` in `vite.config.ts`. + debugIdsWithoutSourceMap: '.tmp_debug_ids_without_source_map', +}); + +console.log('All sourcemap assertions passed!'); 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 ed739895d0d9..eb862db2b1e1 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -4,7 +4,7 @@ "version": "0.0.1", "type": "module", "scripts": { - "build": "vite build && cp instrument.server.mjs .output/server", + "build": "node start-mock-sentry-server.mjs & (vite build && cp instrument.server.mjs .output/server) > .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", "dev": "vite dev", "start": "node --import ./.output/server/instrument.server.mjs .output/server/index.mjs", "test": "playwright test", @@ -13,15 +13,16 @@ "test:build:tunnel-generated": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", "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:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 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", + "test:assert": "node assert-build.ts && pnpm test:assert:proxy", "test:assert:tunnel-generated": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", "test:assert:tunnel-static": "E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", "test:assert:tunnel-custom": "E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm test", - "test:assert:tunnel-object": "E2E_TEST_TUNNEL_ROUTE_MODE=object E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test" + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test tunnel", + "test:assert:tunnel-basepath-dev": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=development pnpm test tunnel" }, "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", "dependencies": { @@ -47,9 +48,6 @@ "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "variants": [ { @@ -72,9 +70,14 @@ "assert-command": "pnpm test:assert:tunnel-custom" }, { - "label": "tanstackstart-react (tunnel-object)", - "build-command": "pnpm test:build:tunnel-object", - "assert-command": "pnpm test:assert:tunnel-object" + "label": "tanstackstart-react (tunnel-basepath)", + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath" + }, + { + "label": "tanstackstart-react (tunnel-basepath-dev)", + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-basepath-dev" } ] } diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/client.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/client.tsx index fc489cd7bdeb..780672f38299 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/client.tsx +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/client.tsx @@ -1,3 +1,4 @@ +// SOURCEMAP_MARKER_CLIENT // Imported first so Sentry.init() runs before any other import's side effects. import './instrument.client'; import './early-side-effect'; diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/server.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/server.ts index b10a3bc1e37b..f7376fa305e2 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/server.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/server.ts @@ -1,3 +1,4 @@ +// SOURCEMAP_MARKER_SERVER import { wrapFetchWithSentry } from '@sentry/tanstackstart-react'; import handler, { createServerEntry } from '@tanstack/react-start/server-entry'; diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/start-mock-sentry-server.mjs b/dev-packages/e2e-tests/test-applications/tanstackstart-react/start-mock-sentry-server.mjs new file mode 100644 index 000000000000..cce37a5f9daf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/start-mock-sentry-server.mjs @@ -0,0 +1,3 @@ +import { startMockSentryServer } from '@sentry-internal/test-utils'; + +startMockSentryServer(); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts index 9857d951b2d8..d42510f36173 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts @@ -3,14 +3,14 @@ import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/t const tunnelRouteMode = process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? (process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1' ? 'custom' : 'off'); +const basePath = process.env.E2E_TEST_BASE_PATH ?? ''; const expectedTunnelPathMatcher = tunnelRouteMode === 'static' ? '/monitor' : tunnelRouteMode === 'custom' ? '/custom-monitor' - : tunnelRouteMode === 'object' - ? '/object-monitor' - : /^\/[a-z0-9]{8}$/; + : // The path `tunnelRoute: true` derives from the DSN. + new RegExp(`^${basePath}/[a-z0-9]{1,7}$`); test.skip(tunnelRouteMode === 'off', 'Tunnel assertions only run in the tunnel-route variants'); @@ -19,7 +19,7 @@ test('Sends client-side errors through the configured tunnel route', async ({ pa return errorEvent?.exception?.values?.[0]?.value === 'Sentry Client Test Error'; }); - await page.goto('/'); + await page.goto(`${basePath}/`); const pageOrigin = new URL(page.url()).origin; // Wait for React to hydrate (see __root.tsx) before clicking — the SSR HTML @@ -54,11 +54,11 @@ test('Sends client-side errors through the configured tunnel route', async ({ pa expect(managedTunnelUrl.pathname).toBe(expectedTunnelPathMatcher); } else { expect(managedTunnelUrl.pathname).toMatch(expectedTunnelPathMatcher); - expect(managedTunnelUrl.pathname).not.toBe('/monitor'); + expect(managedTunnelUrl.pathname).not.toBe(`${basePath}/monitor`); } expect(errorEvent.exception?.values?.[0]?.value).toBe('Sentry Client Test Error'); - expect(errorEvent.transaction).toBe('/'); + expect(errorEvent.transaction).toBe(`${basePath}/`); }); test('Rejects non-POST requests to the managed tunnel route instead of rendering the app', async ({ request }) => { @@ -101,7 +101,7 @@ test('Does not create a server transaction for the tunnel route', async ({ page // transaction by the server SDK — tunnel traffic is plumbing, not application requests. const tunnelServerEventPromise = waitForServerHttpSpan(pathnameMatchesTunnelRoute); - await page.goto('/'); + await page.goto(`${basePath}/`); const pageOrigin = new URL(page.url()).origin; await page.locator('html[data-hydrated="true"]').waitFor(); @@ -127,7 +127,7 @@ test('Does not create a server transaction for the tunnel route', async ({ page // this anchor. Racing the two lets us assert the absence of a tunnel transaction without idling on a timeout. const anchorServerEventPromise = waitForServerHttpSpan(pathname => pathname.includes('/api/user/')); - await page.request.get('/api/user/456'); + await page.request.get(`${basePath}/api/user/456`); const winner = await Promise.race([ tunnelServerEventPromise.then(() => 'tunnel' as const), diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts index 2385d8aa5e93..9883b1add4b3 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts @@ -1,10 +1,31 @@ import { defineConfig } from 'vite'; +import * as fs from 'node:fs'; import tsConfigPaths from 'vite-tsconfig-paths'; import { tanstackStart } from '@tanstack/react-start/plugin/vite'; import viteReact from '@vitejs/plugin-react-swc'; import { nitro } from 'nitro/vite'; import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite'; +// Rolldown emits no map for chunks without mappable source (e.g. Vite's preload helper), so their debug +// IDs can't be uploaded. Recorded here because the SDK deletes all maps after the upload. +function recordChunksWithoutSourceMap() { + return { + name: 'record-chunks-without-source-map', + apply: 'build' as const, + enforce: 'post' as const, + writeBundle(_options: unknown, bundle: Record) { + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk' || !output.code || bundle[`${output.fileName}.map`]) { + continue; + } + for (const match of output.code.matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { + fs.appendFileSync('.tmp_debug_ids_without_source_map', `${match[1]!.toLowerCase()}\n`); + } + } + }, + }; +} + const tunnelRouteMode = process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off'; const useManagedTunnelRoute = tunnelRouteMode !== 'off'; const useCustomTunnelRoute = process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; @@ -26,8 +47,6 @@ function resolveTunnelRouteOption() { return true; case 'static': return '/monitor'; - case 'object': - return { path: '/object-monitor', allowedDsns: [appDsn] }; default: return undefined; } @@ -45,16 +64,22 @@ export default defineConfig({ }, plugins: [ tsConfigPaths(), - tanstackStart(), + // Start routes the whole app under the base path, so the tunnel has to be served under it too. + tanstackStart( + process.env.E2E_TEST_BASE_PATH ? { router: { basepath: process.env.E2E_TEST_BASE_PATH } } : undefined, + ), nitro(), // react's vite plugin must come after start's vite plugin viteReact(), sentryTanstackStart({ - org: process.env.E2E_TEST_SENTRY_ORG_SLUG, - project: process.env.E2E_TEST_SENTRY_PROJECT, - authToken: process.env.E2E_TEST_AUTH_TOKEN, + sentryUrl: 'http://localhost:3032', + authToken: 'fake-auth-token', + org: 'test-org', + project: 'test-project', + release: { name: 'test-release' }, debug: true, tunnelRoute, }), + recordChunksWithoutSourceMap(), ], }); diff --git a/dev-packages/e2e-tests/test-applications/tsx-express/package.json b/dev-packages/e2e-tests/test-applications/tsx-express/package.json index 14bc17d7fcb4..d90221546093 100644 --- a/dev-packages/e2e-tests/test-applications/tsx-express/package.json +++ b/dev-packages/e2e-tests/test-applications/tsx-express/package.json @@ -25,8 +25,5 @@ "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "tsx": "^4.20.3" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/vinext-cf-workers/package.json b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/package.json index 4833ede35725..38ddd3934aa1 100644 --- a/dev-packages/e2e-tests/test-applications/vinext-cf-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/package.json @@ -13,7 +13,7 @@ "dependencies": { "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", - "next": "16.3.8", + "next": "16.4.1", "react": "^19.3.0", "react-dom": "^19.3.0" }, @@ -31,8 +31,5 @@ "vinext": "^1.0.1", "vite": "^8.3.0", "wrangler": "^4.135.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/vue-3-static/package.json b/dev-packages/e2e-tests/test-applications/vue-3-static/package.json index aa74d06bd3ad..ea53e5e51aaa 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3-static/package.json +++ b/dev-packages/e2e-tests/test-applications/vue-3-static/package.json @@ -39,8 +39,5 @@ "typescript": "~5.3.0", "vite": "^5.4.11", "vue-tsc": "^1.8.27" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/vue-3/package.json b/dev-packages/e2e-tests/test-applications/vue-3/package.json index dc315b2fa624..60707aed87b5 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/package.json +++ b/dev-packages/e2e-tests/test-applications/vue-3/package.json @@ -43,9 +43,6 @@ "vite": "^5.4.11", "vue-tsc": "^1.8.27" }, - "volta": { - "extends": "../../package.json" - }, "sentryTest": { "variants": [ { diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/package.json b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/package.json index 190bb8024b68..f93f6f44d3d7 100644 --- a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/package.json +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/package.json @@ -26,8 +26,5 @@ "@vue/tsconfig": "^0.5.1", "typescript": "~5.3.0", "vite": "^5.4.11" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tests/routing-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tests/routing-instrumentation.test.ts index 0f243a9ea0c0..2141b22d9330 100644 --- a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tests/routing-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tests/routing-instrumentation.test.ts @@ -67,7 +67,7 @@ test('sends a pageload span for the root route with web vital attributes and a s expect(lcpSpan.attributes).toMatchObject({ 'sentry.op': { type: 'string', value: 'ui.webvital.lcp' }, - 'sentry.origin': { type: 'string', value: 'auto.http.browser.lcp' }, + 'sentry.origin': { type: 'string', value: 'auto.ui.browser.lcp' }, 'sentry.pageload.span_id': { type: 'string', value: pageloadSpan.span_id }, 'browser.web_vital.lcp.value': webVitalNumber, }); diff --git a/dev-packages/e2e-tests/test-applications/webpack-4/package.json b/dev-packages/e2e-tests/test-applications/webpack-4/package.json index 95f8b55d2f32..7509d22c09d2 100644 --- a/dev-packages/e2e-tests/test-applications/webpack-4/package.json +++ b/dev-packages/e2e-tests/test-applications/webpack-4/package.json @@ -18,8 +18,5 @@ "terser-webpack-plugin": "^4.2.3", "html-webpack-plugin": "^4.5.2", "serve": "^14.2.1" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/webpack-5/package.json b/dev-packages/e2e-tests/test-applications/webpack-5/package.json index 8e31878164e3..91d23df12fbc 100644 --- a/dev-packages/e2e-tests/test-applications/webpack-5/package.json +++ b/dev-packages/e2e-tests/test-applications/webpack-5/package.json @@ -15,8 +15,5 @@ "terser-webpack-plugin": "^5.3.10", "html-webpack-plugin": "^5.6.0", "serve": "^14.2.1" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/external-contributor-gh-action/package.json b/dev-packages/external-contributor-gh-action/package.json index 7a87bc18db07..4b705d1f9185 100644 --- a/dev-packages/external-contributor-gh-action/package.json +++ b/dev-packages/external-contributor-gh-action/package.json @@ -18,8 +18,5 @@ }, "devDependencies": { "eslint-plugin-regexp": "^3.1.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 109c624684e6..33358dd59fc2 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -56,7 +56,9 @@ "@sentry/core": "11.9.0", "@sentry/hono": "11.9.0", "@sentry/node": "11.9.0", + "@sentry/server-runtime-injection": "11.9.0", "@sentry/server-utils": "11.9.0", + "@smithy/node-http-handler": "4.12.1", "@types/mongodb": "^3.6.20", "@types/mysql": "^2.15.21", "@types/pg": "^8.6.5", @@ -68,7 +70,7 @@ "cron": "^3.1.6", "dataloader": "2.2.2", "express": "^4.21.2", - "fastify": "^5.12.5", + "fastify": "^5.12.7", "generic-pool": "^3.9.0", "graphql": "^16.11.0", "groq-sdk": "1.6.0", @@ -112,26 +114,28 @@ "yargs": "^16.2.0" }, "devDependencies": { + "@hapi/boom": "^10.0.1", "@neondatabase/serverless": "1.2.0", "@opentelemetry/instrumentation-http": "0.220.0", - "@sentry/conventions": "0.27.0", + "@sentry-internal/rollup-utils": "11.9.0", + "@sentry/conventions": "0.29.0", "@sentry-internal/test-utils": "11.9.0", + "@sentry/node-native": "11.9.0", "@types/amqplib": "^0.10.5", + "@types/express": "^4.17.21", "@types/node-cron": "^3.0.11", "@types/node-schedule": "^2.1.7", "esbuild": "0.28.1", "eslint-plugin-regexp": "^3.1.0", "globby": "11", "react": "^18.3.1", - "zod": "^3.24.1" + "winston-transport": "^4.9.0", + "zod": "^4.6.7" }, "config": { "mongodbMemoryServer": { "preferGlobalPath": true, "runtimeDownload": false } - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/node-integration-tests/scripts/use-ts-5_0.js b/dev-packages/node-integration-tests/scripts/use-ts-5_0.js deleted file mode 100644 index 281bb4e9dcb7..000000000000 --- a/dev-packages/node-integration-tests/scripts/use-ts-5_0.js +++ /dev/null @@ -1,16 +0,0 @@ -/* eslint-disable no-console */ -const { execSync } = require('child_process'); -const { join } = require('path'); - -const cwd = join(__dirname, '../../..'); - -const tsVersion = '5.0.4'; - -console.log(`Installing typescript@${tsVersion}, and @types/node@18...`); - -execSync(`pnpm add --save-dev --workspace-root typescript@${tsVersion} @types/node@^18`, { - stdio: 'inherit', - cwd, -}); - -console.log('TypeScript version updated successfully.'); diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts index 951a0e437e38..f91142fc4e0b 100644 --- a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts +++ b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts @@ -5,7 +5,9 @@ import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runn // The suite runs twice on CI: once with the OTel `Aws` integration (default) and once with the // orchestrion diagnostics-channel integration auto-injected (`INJECT_ORCHESTRION`). Both emit the // same spans; only the origin differs. -const ORIGIN = 'auto.aws.aws_sdk'; +const RPC_ORIGIN = 'auto.rpc.aws_sdk'; +const DB_ORIGIN = 'auto.db.aws_sdk'; +const QUEUE_ORIGIN = 'auto.queue.aws_sdk'; // The aws-sdk instrumentation creates spans by patching the underlying smithy middleware stack. The // patch target differs between aws-sdk versions, so we run the exact same assertions against both: @@ -52,7 +54,7 @@ function assertAwsServiceSpans(spans: SerializedStreamedSpanContainer['items']): name: 'S3.PutObject', status: 'ok', attributes: expect.objectContaining({ - 'sentry.origin': { value: ORIGIN, type: 'string' }, + 'sentry.origin': { value: RPC_ORIGIN, type: 'string' }, 'sentry.op': { value: 'rpc', type: 'string' }, 'rpc.system': { value: 'aws-api', type: 'string' }, 'rpc.method': { value: 'PutObject', type: 'string' }, @@ -71,7 +73,7 @@ function assertAwsServiceSpans(spans: SerializedStreamedSpanContainer['items']): status: 'ok', attributes: expect.objectContaining({ 'sentry.op': { value: 'rpc', type: 'string' }, - 'sentry.origin': { value: ORIGIN, type: 'string' }, + 'sentry.origin': { value: RPC_ORIGIN, type: 'string' }, 'rpc.method': { value: 'GetObject', type: 'string' }, 'rpc.service': { value: 'S3', type: 'string' }, 'aws.s3.bucket': { value: 'ot-demo-test', type: 'string' }, @@ -89,7 +91,7 @@ function assertAwsServiceSpans(spans: SerializedStreamedSpanContainer['items']): status: 'error', attributes: expect.objectContaining({ 'sentry.op': { value: 'rpc', type: 'string' }, - 'sentry.origin': { value: ORIGIN, type: 'string' }, + 'sentry.origin': { value: RPC_ORIGIN, type: 'string' }, 'rpc.method': { value: 'GetObject', type: 'string' }, 'rpc.service': { value: 'S3', type: 'string' }, }), @@ -102,7 +104,7 @@ function assertAwsServiceSpans(spans: SerializedStreamedSpanContainer['items']): name: 'DynamoDB.PutItem', attributes: expect.objectContaining({ 'sentry.op': { value: 'db', type: 'string' }, - 'sentry.origin': { value: ORIGIN, type: 'string' }, + 'sentry.origin': { value: DB_ORIGIN, type: 'string' }, 'rpc.method': { value: 'PutItem', type: 'string' }, 'rpc.service': { value: 'DynamoDB', type: 'string' }, 'db.system.name': { value: 'dynamodb', type: 'string' }, @@ -117,7 +119,7 @@ function assertAwsServiceSpans(spans: SerializedStreamedSpanContainer['items']): name: 'DynamoDB.Query', attributes: expect.objectContaining({ 'sentry.op': { value: 'db', type: 'string' }, - 'sentry.origin': { value: ORIGIN, type: 'string' }, + 'sentry.origin': { value: DB_ORIGIN, type: 'string' }, 'rpc.method': { value: 'Query', type: 'string' }, 'db.operation.name': { value: 'Query', type: 'string' }, 'aws.dynamodb.count': { value: 1, type: 'integer' }, @@ -129,7 +131,7 @@ function assertAwsServiceSpans(spans: SerializedStreamedSpanContainer['items']): expectSpan('SQS SendMessage', { name: 'send my-queue', attributes: expect.objectContaining({ - 'sentry.origin': { value: ORIGIN, type: 'string' }, + 'sentry.origin': { value: QUEUE_ORIGIN, type: 'string' }, 'sentry.op': { value: 'queue.publish', type: 'string' }, 'rpc.method': { value: 'SendMessage', type: 'string' }, 'rpc.service': { value: 'SQS', type: 'string' }, @@ -145,7 +147,7 @@ function assertAwsServiceSpans(spans: SerializedStreamedSpanContainer['items']): expectSpan('SQS ReceiveMessage', { name: 'receive my-queue', attributes: expect.objectContaining({ - 'sentry.origin': { value: ORIGIN, type: 'string' }, + 'sentry.origin': { value: QUEUE_ORIGIN, type: 'string' }, 'sentry.op': { value: 'queue.receive', type: 'string' }, 'rpc.method': { value: 'ReceiveMessage', type: 'string' }, 'messaging.system': { value: 'aws_sqs', type: 'string' }, @@ -159,7 +161,7 @@ function assertAwsServiceSpans(spans: SerializedStreamedSpanContainer['items']): expectSpan('SNS Publish', { name: 'send my-topic', attributes: expect.objectContaining({ - 'sentry.origin': { value: ORIGIN, type: 'string' }, + 'sentry.origin': { value: QUEUE_ORIGIN, type: 'string' }, 'rpc.method': { value: 'Publish', type: 'string' }, 'rpc.service': { value: 'SNS', type: 'string' }, 'messaging.system': { value: 'aws.sns', type: 'string' }, @@ -190,7 +192,7 @@ function assertAwsServiceSpans(spans: SerializedStreamedSpanContainer['items']): name: 'my-function Invoke', attributes: expect.objectContaining({ 'sentry.op': { value: 'rpc', type: 'string' }, - 'sentry.origin': { value: ORIGIN, type: 'string' }, + 'sentry.origin': { value: RPC_ORIGIN, type: 'string' }, 'rpc.method': { value: 'Invoke', type: 'string' }, 'rpc.service': { value: 'Lambda', type: 'string' }, 'faas.invoked_name': { value: 'my-function', type: 'string' }, @@ -205,7 +207,7 @@ function assertAwsServiceSpans(spans: SerializedStreamedSpanContainer['items']): status: 'ok', attributes: expect.objectContaining({ 'sentry.op': { value: 'rpc', type: 'string' }, - 'sentry.origin': { value: ORIGIN, type: 'string' }, + 'sentry.origin': { value: RPC_ORIGIN, type: 'string' }, 'rpc.method': { value: 'PutRecord', type: 'string' }, 'rpc.service': { value: 'Kinesis', type: 'string' }, 'aws.kinesis.stream.name': { value: 'my-stream', type: 'string' }, @@ -217,7 +219,7 @@ function assertAwsServiceSpans(spans: SerializedStreamedSpanContainer['items']): name: 'SecretsManager.GetSecretValue', attributes: expect.objectContaining({ 'sentry.op': { value: 'rpc', type: 'string' }, - 'sentry.origin': { value: ORIGIN, type: 'string' }, + 'sentry.origin': { value: RPC_ORIGIN, type: 'string' }, 'rpc.method': { value: 'GetSecretValue', type: 'string' }, 'rpc.service': { value: 'SecretsManager', type: 'string' }, 'aws.secretsmanager.secret.arn': { @@ -232,7 +234,7 @@ function assertAwsServiceSpans(spans: SerializedStreamedSpanContainer['items']): name: 'SFN.StartExecution', attributes: expect.objectContaining({ 'sentry.op': { value: 'rpc', type: 'string' }, - 'sentry.origin': { value: ORIGIN, type: 'string' }, + 'sentry.origin': { value: RPC_ORIGIN, type: 'string' }, 'rpc.method': { value: 'StartExecution', type: 'string' }, 'rpc.service': { value: 'SFN', type: 'string' }, 'aws.step_functions.state_machine.arn': { diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/test.ts b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/test.ts index a7bc374499b4..0a86d5232552 100644 --- a/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/test.ts +++ b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/test.ts @@ -24,7 +24,7 @@ function assertBedrockSpans(container: SerializedStreamedSpanContainer): void { // Converse (non-streaming) const converseSpan = container.items.find(span => span.name === `chat ${MODEL_ID}`); expect(converseSpan!.status).toBe('ok'); - expect(converseSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.aws.aws_sdk'); + expect(converseSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.aws_sdk'); expect(converseSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.chat'); expect(converseSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('aws.bedrock'); expect(converseSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat'); @@ -39,7 +39,7 @@ function assertBedrockSpans(container: SerializedStreamedSpanContainer): void { // InvokeModel (non-streaming, anthropic.claude request/response body) const invokeModelSpan = container.items.find(span => span.name === `generate_content ${MODEL_ID}`); expect(invokeModelSpan!.status).toBe('ok'); - expect(invokeModelSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.aws.aws_sdk'); + expect(invokeModelSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.aws_sdk'); expect(invokeModelSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content'); expect(invokeModelSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('aws.bedrock'); expect(invokeModelSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content'); diff --git a/dev-packages/node-integration-tests/suites/modules/scenario-isolated.js b/dev-packages/node-integration-tests/suites/modules/scenario-isolated.js new file mode 100644 index 000000000000..29f9e62decca --- /dev/null +++ b/dev-packages/node-integration-tests/suites/modules/scenario-isolated.js @@ -0,0 +1,18 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +// `test.ts` writes these packages in the folders where the isolated layouts of pnpm and Bun keep them +require('pnpm-isolated-package'); +require('@pnpm-isolated/scoped-package'); +require('bun-isolated-package'); +require('@bun-isolated/scoped-package'); +// Another version of `pnpm-isolated-package`, loaded last as a dependency of the app would load it +require('./node_modules/.pnpm/pnpm-isolated-package@1.0.0/node_modules/pnpm-isolated-package'); + +Sentry.captureException(new Error('error_1')); diff --git a/dev-packages/node-integration-tests/suites/modules/test.ts b/dev-packages/node-integration-tests/suites/modules/test.ts index f47c4c1f8b09..57c221f9f7f0 100644 --- a/dev-packages/node-integration-tests/suites/modules/test.ts +++ b/dev-packages/node-integration-tests/suites/modules/test.ts @@ -1,5 +1,6 @@ import { SDK_VERSION } from '@sentry/core'; -import { join } from 'path'; +import { mkdirSync, rmSync, symlinkSync, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../utils/runner'; @@ -27,6 +28,46 @@ describe('modulesIntegration', () => { await runner.completed(); }); + test('CJS with the isolated layouts of pnpm and Bun', async () => { + const nodeModulesDir = join(__dirname, 'node_modules'); + for (const [storeDir, name, version, linked] of [ + ['.pnpm', 'pnpm-isolated-package', '1.2.3', true], + // Only a dependency of the app uses this version, so it is not linked into `node_modules` + ['.pnpm', 'pnpm-isolated-package', '1.0.0', false], + ['.pnpm', '@pnpm-isolated/scoped-package', '2.3.4', true], + ['.bun', 'bun-isolated-package', '4.5.6', true], + ['.bun', '@bun-isolated/scoped-package', '5.6.7', true], + ] as const) { + const packageDir = join(nodeModulesDir, storeDir, `${name.replace('/', '+')}@${version}`, 'node_modules', name); + mkdirSync(packageDir, { recursive: true }); + writeFileSync(join(packageDir, 'package.json'), JSON.stringify({ name, version })); + writeFileSync(join(packageDir, 'index.js'), 'module.exports = {};'); + + if (linked) { + mkdirSync(dirname(join(nodeModulesDir, name)), { recursive: true }); + symlinkSync(packageDir, join(nodeModulesDir, name), 'junction'); + } + } + + try { + await createRunner(__dirname, 'scenario-isolated.js') + .expect({ + event: { + modules: { + 'pnpm-isolated-package': '1.2.3', + '@pnpm-isolated/scoped-package': '2.3.4', + 'bun-isolated-package': '4.5.6', + '@bun-isolated/scoped-package': '5.6.7', + }, + }, + }) + .start() + .completed(); + } finally { + rmSync(nodeModulesDir, { recursive: true, force: true }); + } + }); + test('ESM', async () => { const runner = createRunner(__dirname, 'server.mjs') .withInstrument(join(__dirname, 'instrument.mjs')) diff --git a/dev-packages/node-integration-tests/suites/tracing/amqplib/test.ts b/dev-packages/node-integration-tests/suites/tracing/amqplib/test.ts index cd86d01459ec..5dd002898bff 100644 --- a/dev-packages/node-integration-tests/suites/tracing/amqplib/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/amqplib/test.ts @@ -36,7 +36,7 @@ const expectedProducerSpan = (routingKey: string) => [URL_FULL]: { type: 'string', value: 'amqp://sentry:***@localhost:5672/' }, [SENTRY_KIND]: { type: 'string', value: 'producer' }, [SENTRY_OP]: { type: 'string', value: QUEUE_PUBLISH }, - [SENTRY_ORIGIN]: { type: 'string', value: 'auto.amqplib.publisher' }, + [SENTRY_ORIGIN]: { type: 'string', value: 'auto.queue.amqplib.publisher' }, }), status: 'ok', }); @@ -51,7 +51,7 @@ const EXPECTED_MESSAGE_SPAN_CONSUMER = expect.objectContaining({ [MESSAGING_OPERATION_TYPE]: { type: 'string', value: 'process' }, [SENTRY_KIND]: { type: 'string', value: 'consumer' }, [SENTRY_OP]: { type: 'string', value: QUEUE_PROCESS }, - [SENTRY_ORIGIN]: { type: 'string', value: 'auto.amqplib.consumer' }, + [SENTRY_ORIGIN]: { type: 'string', value: 'auto.queue.amqplib.consumer' }, }), status: 'ok', }); @@ -77,7 +77,7 @@ describeWithDockerCompose('amqplib auto-instrumentation', { workingDirectory: [_ span: container => { const producer = container.items.find(span => span.is_segment && span.name === 'root span'); const consumer = container.items.find( - t => t.attributes[SENTRY_ORIGIN]?.value === 'auto.amqplib.consumer', + t => t.attributes[SENTRY_ORIGIN]?.value === 'auto.queue.amqplib.consumer', ); expect(producer).toBeDefined(); @@ -87,7 +87,7 @@ describeWithDockerCompose('amqplib auto-instrumentation', { workingDirectory: [_ expect(consumer!.name).toBe('process queue1'); const producerSpan = container.items.find( - s => s.attributes[SENTRY_ORIGIN]?.value === 'auto.amqplib.publisher', + s => s.attributes[SENTRY_ORIGIN]?.value === 'auto.queue.amqplib.publisher', ); expect(producerSpan?.name).toBe('send queue1'); expect(producerSpan).toMatchObject(expectedProducerSpan('queue1')); @@ -112,7 +112,7 @@ describeWithDockerCompose('amqplib auto-instrumentation', { workingDirectory: [_ .expect({ span: container => { const consumer = container.items.find( - t => t.attributes[SENTRY_ORIGIN]?.value === 'auto.amqplib.consumer', + t => t.attributes[SENTRY_ORIGIN]?.value === 'auto.queue.amqplib.consumer', ); expect(consumer).toBeDefined(); @@ -124,7 +124,7 @@ describeWithDockerCompose('amqplib auto-instrumentation', { workingDirectory: [_ [MESSAGING_SYSTEM]: { type: 'string', value: 'rabbitmq' }, [SENTRY_KIND]: { type: 'string', value: 'consumer' }, [SENTRY_OP]: { type: 'string', value: QUEUE_PROCESS }, - [SENTRY_ORIGIN]: { type: 'string', value: 'auto.amqplib.consumer' }, + [SENTRY_ORIGIN]: { type: 'string', value: 'auto.queue.amqplib.consumer' }, }), }), ); @@ -152,7 +152,7 @@ describeWithDockerCompose('amqplib auto-instrumentation', { workingDirectory: [_ expect(container.items.find(span => span.is_segment)?.name).toBe('root span'); const producerSpans = container.items.filter( - s => s.attributes[SENTRY_ORIGIN]?.value === 'auto.amqplib.publisher', + s => s.attributes[SENTRY_ORIGIN]?.value === 'auto.queue.amqplib.publisher', ); // The confirm channel internally calls the base publish; the instrumentation must not @@ -179,7 +179,7 @@ describeWithDockerCompose('amqplib auto-instrumentation', { workingDirectory: [_ .expect({ span: container => { const consumer = container.items.find( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.amqplib.consumer', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.queue.amqplib.consumer', ); expect(consumer).toBeDefined(); @@ -205,12 +205,12 @@ describeWithDockerCompose('amqplib auto-instrumentation', { workingDirectory: [_ .expect({ span: container => { const producerSpan = container.items.find( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.amqplib.publisher', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.queue.amqplib.publisher', ); expect(producerSpan?.name).toBe('send callback-queue'); const consumerSpan = container.items.find( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.amqplib.consumer', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.queue.amqplib.consumer', ); expect(consumerSpan?.name).toBe('process callback-queue'); expect(consumerSpan?.status).toBe('ok'); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/scenario-session-tool-runner.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/scenario-session-tool-runner.mjs new file mode 100644 index 000000000000..07658baa87c1 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/scenario-session-tool-runner.mjs @@ -0,0 +1,107 @@ +import Anthropic from '@anthropic-ai/sdk'; +import { SessionToolRunner } from '@anthropic-ai/sdk/resources/beta/sessions/events'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +const STREAM_EVENTS = { + sesn_weather: [ + { type: 'agent.tool_use', id: 'sevt_weather', name: 'weather', input: { city: 'Vienna' } }, + { type: 'agent.tool_use', id: 'sevt_explode', name: 'explode', input: {} }, + { type: 'session.status_terminated', id: 'sevt_weather_end' }, + ], + // Served by a runner built with `new SessionToolRunner()`, as `EnvironmentWorker` does. + sesn_direct: [ + { type: 'agent.tool_use', id: 'sevt_read', name: 'read', input: { file_path: 'README.md' } }, + { type: 'session.status_terminated', id: 'sevt_direct_end' }, + ], +}; + +function startMockSessionsServer() { + const app = express(); + app.use(express.json()); + + app.get('/anthropic/v1/sessions/:sessionId/events/stream', (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); + + const events = STREAM_EVENTS[req.params.sessionId]; + events.forEach((event, index) => { + setTimeout(() => { + res.write(`event: ${event.type}\n`); + res.write(`data: ${JSON.stringify(event)}\n\n`); + if (index === events.length - 1) { + res.end(); + } + }, index * 10); + }); + }); + app.get('/anthropic/v1/sessions/:sessionId/events', (_req, res) => { + res.json({ data: [], next_page: null }); + }); + app.post('/anthropic/v1/sessions/:sessionId/events', (_req, res) => { + res.json({ data: [] }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function drain(runner) { + for await (const _call of runner) { + // drain + } +} + +async function run() { + const server = await startMockSessionsServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new Anthropic({ + apiKey: 'mock-api-key', + baseURL: `http://localhost:${server.address().port}/anthropic`, + }); + + await drain( + client.beta.sessions.events.toolRunner('sesn_weather', { + tools: [ + { + name: 'weather', + description: 'Get the weather by city', + input_schema: { type: 'object', properties: { city: { type: 'string' } } }, + run: () => 'sunny', + }, + { + name: 'explode', + description: 'Always fails', + input_schema: { type: 'object', properties: {} }, + run: () => { + throw new Error('Tool exploded'); + }, + }, + ], + }), + ); + + await drain( + new SessionToolRunner('sesn_direct', { + client, + tools: [ + { + name: 'read', + description: 'Read a file', + input_schema: { type: 'object', properties: { file_path: { type: 'string' } } }, + run: () => '# Weather app', + }, + ], + }), + ); + }); + + await Sentry.flush(2000); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/test.ts index bc4c9fef4d35..3c66fe8cb531 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/test.ts @@ -137,4 +137,67 @@ describe('Anthropic integration (0.129)', () => { }, }, ); + + createEsmAndCjsTests( + __dirname, + 'scenario-session-tool-runner.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('emits an execute_tool span for each tool a session runner runs, however it was created', async () => { + let receivedEvent: Event | undefined; + let receivedSpans: SerializedStreamedSpanContainer['items'] = []; + + await createRunner() + .unordered() + .expect({ + span: container => { + expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined(); + receivedSpans = container.items; + }, + }) + .expect({ + event: event => { + receivedEvent = event; + }, + }) + .start() + .completed(); + + const main = receivedSpans.find(span => span.is_segment && span.name === 'main')!; + const toolSpans = receivedSpans.filter(span => span.attributes[SENTRY_OP]?.value === GEN_AI_EXECUTE_TOOL); + expect(toolSpans).toHaveLength(3); + const weather = toolSpans.find(span => span.attributes[GEN_AI_TOOL_NAME]?.value === 'weather')!; + const explode = toolSpans.find(span => span.attributes[GEN_AI_TOOL_NAME]?.value === 'explode')!; + const read = toolSpans.find(span => span.attributes[GEN_AI_TOOL_NAME]?.value === 'read')!; + + expect(weather).toMatchObject({ + name: 'execute_tool weather', + parent_span_id: main.span_id, + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: { type: 'string', value: 'sevt_weather' }, + }), + }); + expect(explode).toMatchObject({ name: 'execute_tool explode', parent_span_id: main.span_id, status: 'error' }); + expect(read).toMatchObject({ + name: 'execute_tool read', + parent_span_id: main.span_id, + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: { type: 'string', value: 'sevt_read' }, + }), + }); + expect(receivedEvent).toMatchObject({ + exception: { + values: [{ value: 'Tool exploded', mechanism: { type: 'auto.ai.anthropic', handled: true } }], + }, + }); + }); + }, + { + additionalDependencies: { + '@anthropic-ai/sdk': '0.129.0', + }, + }, + ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/flue/scenario-separate-requests.mjs b/dev-packages/node-integration-tests/suites/tracing/flue/scenario-separate-requests.mjs new file mode 100644 index 000000000000..13a54ffc9d25 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/flue/scenario-separate-requests.mjs @@ -0,0 +1,37 @@ +import * as Sentry from '@sentry/node'; +import { __flueBindAgentModule, init, instrument, useModel } from '@flue/runtime'; +import { start } from '@flue/runtime/node'; +import { fauxAssistantMessage, fauxProvider } from '@earendil-works/pi-ai/providers/faux'; + +instrument(Sentry.createFlueInstrumentation()); + +const faux = fauxProvider({ + provider: 'faux', + models: [{ id: 'faux-model', cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 } }], +}); +faux.setResponses([fauxAssistantMessage('Hello, a.'), fauxAssistantMessage('Hello, b.')]); + +function Hello() { + useModel('faux/faux-model'); + return 'You are a helpful assistant.'; +} +__flueBindAgentModule(Hello, { identity: 'Hello' }); + +const flue = await start({ agents: [Hello], providers: [faux.provider] }); + +// Each request gets its own isolation scope and trace, as the http integration gives a real one. The +// first dispatch starts Flue's claim loop, which keeps request-a's context for every later run. +for (const name of ['request-a', 'request-b']) { + await Sentry.withIsolationScope(() => + Sentry.startNewTrace(() => + Sentry.startSpan({ name, op: 'http.server' }, async () => { + const agent = init(Hello, { id: name }); + const receipt = await agent.dispatch('Say hello.'); + await agent.read(receipt); + }), + ), + ); +} + +await flue[Symbol.asyncDispose]?.(); +await Sentry.flush(2000); diff --git a/dev-packages/node-integration-tests/suites/tracing/flue/test.ts b/dev-packages/node-integration-tests/suites/tracing/flue/test.ts index 5d3b6fe6cc0e..b750ee653e4f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/flue/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/flue/test.ts @@ -1,3 +1,4 @@ +import type { SerializedStreamedSpanContainer } from '@sentry/core'; import { GEN_AI_AGENT_NAME, GEN_AI_CONVERSATION_ID, @@ -175,4 +176,36 @@ describe.runIf(supports({ min: 22 }))('Flue integration', () => { }, FLUE_DEPENDENCIES, ); + + createEsmAndCjsTests( + __dirname, + 'scenario-separate-requests.mjs', + 'instrument.mjs', + (createRunner, test, mode) => { + if (mode === 'cjs') { + return; + } + + test('puts each dispatch in the trace of the request that admitted it', async () => { + const expectAgentUnder = (requestName: string) => ({ + span: (container: SerializedStreamedSpanContainer) => { + const request = container.items.find(span => span.name === requestName)!; + expect(request.is_segment).toBe(true); + + const agents = container.items.filter(span => span.name === 'invoke_agent Hello'); + expect(agents).toHaveLength(1); + expect(agents[0]!.parent_span_id).toBe(request.span_id); + }, + }); + + await createRunner() + .unordered() + .expect(expectAgentUnder('request-a')) + .expect(expectAgentUnder('request-b')) + .start() + .completed(); + }); + }, + FLUE_DEPENDENCIES, + ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/flush-on-exit/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/flush-on-exit/scenario.ts new file mode 100644 index 000000000000..3f0b8ee4ec20 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/flush-on-exit/scenario.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +Sentry.startSpan({ name: 'cron-job' }, () => { + Sentry.startSpan({ name: 'step' }, () => {}); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/flush-on-exit/test.ts b/dev-packages/node-integration-tests/suites/tracing/flush-on-exit/test.ts new file mode 100644 index 000000000000..adf3aae1a124 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/flush-on-exit/test.ts @@ -0,0 +1,14 @@ +import { expect, test } from 'vitest'; +import { createRunner } from '../../../utils/runner'; + +test('sends buffered spans when the process exits on its own', async () => { + await createRunner(__dirname, 'scenario.ts') + .withoutAutoFlush() + .expect({ + span: container => { + expect(container.items.map(span => span.name).sort()).toEqual(['cron-job', 'step']); + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/hapi/instrument-static.mjs b/dev-packages/node-integration-tests/suites/tracing/hapi/instrument-static.mjs new file mode 100644 index 000000000000..d799a8900e59 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/hapi/instrument-static.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + traceLifecycle: 'static', +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/hapi/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/hapi/scenario.mjs index d081af0a4295..33a3df241a66 100644 --- a/dev-packages/node-integration-tests/suites/tracing/hapi/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/hapi/scenario.mjs @@ -18,6 +18,18 @@ const run = async () => { }, }); + server.route({ + method: ['GET', 'POST'], + path: '/multi', + handler: () => 'Hello World!', + }); + + server.route({ + method: '*', + path: '/wildcard', + handler: () => 'Hello World!', + }); + server.route({ method: 'GET', path: '/error', @@ -34,6 +46,14 @@ const run = async () => { }, }); + server.route({ + method: '*', + path: '/wildcard-error', + handler: () => { + return new Error('Sentry Test Error'); + }, + }); + server.route({ method: 'GET', path: '/boom-error', diff --git a/dev-packages/node-integration-tests/suites/tracing/hapi/test.ts b/dev-packages/node-integration-tests/suites/tracing/hapi/test.ts index 24a9d1b2adba..c608ea2cbfac 100644 --- a/dev-packages/node-integration-tests/suites/tracing/hapi/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/hapi/test.ts @@ -1,3 +1,11 @@ +import { + HAPI_EXT_TYPE, + HAPI_TYPE, + HTTP_REQUEST_METHOD, + SENTRY_OP, + SERVER_EXT_TYPE, +} from '@sentry/conventions/attributes'; +import { ROUTER } from '@sentry/conventions/op'; import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; @@ -19,7 +27,36 @@ describe('hapi auto-instrumentation', () => { }, }; + const MULTI_METHOD_REQUESTS = [ + { method: 'get', path: '/multi' }, + { method: 'post', path: '/multi' }, + { method: 'put', path: '/wildcard' }, + ] as const; + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('should record the request method for multi-method and wildcard routes.', async () => { + const runner = createRunner().unordered(); + for (const { method, path } of MULTI_METHOD_REQUESTS) { + const httpMethod = method.toUpperCase(); + runner.expect({ + span: container => { + expect(container.items.find(item => item.is_segment)?.name).toBe(`${httpMethod} ${path}`); + + const routerSpans = container.items.filter(item => item.attributes[SENTRY_OP]?.value === ROUTER); + expect(routerSpans).toHaveLength(1); + expect(routerSpans[0]?.name).toBe(path); + expect(routerSpans[0]?.attributes[HTTP_REQUEST_METHOD]).toEqual({ type: 'string', value: httpMethod }); + }, + }); + } + + const started = runner.start(); + for (const { method, path } of MULTI_METHOD_REQUESTS) { + await started.makeRequest(method, path); + } + await started.completed(); + }); + test('should auto-instrument `@hapi/hapi` package.', async () => { const runner = createRunner() .expect({ @@ -72,8 +109,10 @@ describe('hapi auto-instrumentation', () => { expect.objectContaining({ name: 'ext - onPreResponse', attributes: expect.objectContaining({ - 'hapi.type': { type: 'string', value: 'server.ext' }, - 'server.ext.type': { type: 'string', value: 'onPreResponse' }, + [HAPI_TYPE]: { type: 'string', value: 'server.ext' }, + [HAPI_EXT_TYPE]: { type: 'string', value: 'onPreResponse' }, + // oxlint-disable-next-line typescript/no-deprecated + [SERVER_EXT_TYPE]: { type: 'string', value: 'onPreResponse' }, 'sentry.op': { type: 'string', value: 'middleware' }, 'sentry.origin': { type: 'string', value: origin }, }), @@ -114,6 +153,20 @@ describe('hapi auto-instrumentation', () => { await runner.completed(); }); + test('should assign the request method to the transactionName of errors in wildcard routes.', async () => { + const runner = createRunner() + .expect({ + event: { + ...EXPECTED_ERROR_EVENT, + transaction: 'PUT /wildcard-error', + }, + }) + .ignore('span') + .start(); + runner.makeRequest('put', '/wildcard-error', { expectError: true }); + await runner.completed(); + }); + test('should handle returned Boom errors in routes.', async () => { const runner = createRunner() .unordered() @@ -143,6 +196,33 @@ describe('hapi auto-instrumentation', () => { }); }); + // Only static spans put the method into the router span name, which crashed for multi-method routes. + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-static.mjs', (createRunner, test) => { + test('should name router spans after the request method for multi-method and wildcard routes.', async () => { + const runner = createRunner().unordered(); + for (const { method, path } of MULTI_METHOD_REQUESTS) { + const httpMethod = method.toUpperCase(); + const spanName = `${httpMethod} ${path}`; + runner.expect({ + transaction: transaction => { + expect(transaction.transaction).toBe(spanName); + + const routerSpans = transaction.spans?.filter(span => span.op === ROUTER) ?? []; + expect(routerSpans).toHaveLength(1); + expect(routerSpans[0]?.description).toBe(spanName); + expect(routerSpans[0]?.data[HTTP_REQUEST_METHOD]).toBe(httpMethod); + }, + }); + } + + const started = runner.start(); + for (const { method, path } of MULTI_METHOD_REQUESTS) { + await started.makeRequest(method, path); + } + await started.completed(); + }); + }); + // Regression test: a `setupHapiErrorHandler` call before `server.start()` installs the default // predicate. The integration's own auto-registration (with a custom `shouldHandleError`) fires later, // at `server.start()`, and must still win. The custom predicate drops "Dropped error" (which the diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-options.mjs b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-options.mjs index d283ccff7951..6a02d7d4a26c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-options.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-options.mjs @@ -18,6 +18,12 @@ Sentry.init({ 'onSpanCreated.resMethod': res.req.method, }); }, + onSpanEnd: (span, req, res) => { + span.setAttributes({ + 'onSpanEnd.reqUrl': req.url, + 'onSpanEnd.resStatusCode': res.statusCode, + }); + }, }), ], }); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-throwing-callbacks.mjs b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-throwing-callbacks.mjs index c2d7c4763781..6672b0dc99eb 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-throwing-callbacks.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-throwing-callbacks.mjs @@ -15,6 +15,9 @@ Sentry.init({ onSpanCreated: () => { throw new Error('onSpanCreated error'); }, + onSpanEnd: () => { + throw new Error('onSpanEnd error'); + }, }), ], }); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts index 28ae77d23617..70618d73671d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts @@ -1,9 +1,62 @@ import { HTTP_CLIENT, HTTP_SERVER } from '@sentry/conventions/op'; import { createTestServer } from '@sentry-internal/test-utils'; -import { HTTP_RESPONSE_STATUS_CODE, SENTRY_OP, URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; +import { + CLIENT_ADDRESS, + CLIENT_PORT, + HTTP_REQUEST_METHOD, + HTTP_RESPONSE_STATUS_CODE, + HTTP_RESPONSE_STATUS_TEXT, + HTTP_ROUTE, + NETWORK_LOCAL_ADDRESS, + NETWORK_LOCAL_PORT, + NETWORK_PEER_ADDRESS, + NETWORK_PEER_PORT, + NETWORK_PROTOCOL_NAME, + NETWORK_PROTOCOL_VERSION, + NETWORK_TRANSPORT, + SENTRY_ENVIRONMENT, + SENTRY_IS_LOCALHOST, + SENTRY_KIND, + SENTRY_OP, + SENTRY_ORIGIN, + SENTRY_RELEASE, + SENTRY_SDK_INTEGRATIONS, + SENTRY_SDK_NAME, + SENTRY_SDK_VERSION, + SENTRY_SEGMENT_ID, + SENTRY_SEGMENT_NAME, + SENTRY_SEGMENT_NAME_SOURCE, + SENTRY_TRACE_LIFECYCLE, + SERVER_ADDRESS, + SERVER_PORT, + URL_FULL, + URL_PATH, + URL_SCHEME, + USER_AGENT_ORIGINAL, + USER_IP_ADDRESS, +} from '@sentry/conventions/attributes'; +import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE } from '@sentry/core'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests, createRunner } from '../../../utils/runner'; -import { supports } from '../../../utils'; +import { EXPECTED_SDK_NAME, supports } from '../../../utils'; + +const LOOPBACK_ADDRESS = /^(::1|127\.0\.0\.1|::ffff:127\.0\.0\.1)$/; + +// Attributes from the context of the host (app, device, OS, culture, runtime, cloud). Which of them +// the SDK sends depends on the platform, so tests only check that they are well formed. +const HOST_CONTEXT_ATTRIBUTE = /^(app|device|os|culture|process\.runtime|cloud)\./; + +function splitHostContextAttributes(attributes: Record): { + hostContext: Record; + rest: Record; +} { + const hostContext: Record = {}; + const rest: Record = {}; + for (const [key, value] of Object.entries(attributes)) { + (HOST_CONTEXT_ATTRIBUTE.test(key) ? hostContext : rest)[key] = value; + } + return { hostContext, rest }; +} function getCommonHttpRequestHeaders(): Record { return { @@ -22,30 +75,76 @@ describe('httpIntegration', () => { cleanupChildProcesses(); }); - describe('onSpanCreated option', () => { + describe('onSpanCreated and onSpanEnd options', () => { createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument-options.mjs', (createRunner, test) => { - test('allows to configure onSpanCreated', async () => { + test('allows to configure onSpanCreated and onSpanEnd', async () => { const runner = createRunner() .expect({ - span: { - items: expect.arrayContaining([ + span: container => { + const segmentSpan = container.items.find(span => span.is_segment); + expect(segmentSpan).toEqual( expect.objectContaining({ - is_segment: true, span_id: expect.stringMatching(/[a-f\d]{16}/), trace_id: expect.stringMatching(/[a-f\d]{32}/), status: 'ok', - attributes: expect.objectContaining({ - [URL_FULL]: { type: 'string', value: expect.stringMatching(/\/test$/) }, - [HTTP_RESPONSE_STATUS_CODE]: { type: 'integer', value: 200 }, - [SENTRY_OP]: { type: 'string', value: HTTP_SERVER }, - onSpanCreated: { type: 'string', value: 'yes' }, - 'onSpanCreated.reqUrl': { type: 'string', value: expect.stringMatching(/\/test$/) }, - 'onSpanCreated.reqMethod': { type: 'string', value: 'GET' }, - 'onSpanCreated.resUrl': { type: 'string', value: expect.stringMatching(/\/test$/) }, - 'onSpanCreated.resMethod': { type: 'string', value: 'GET' }, - }), }), - ]), + ); + + // Every other attribute is listed, so the test fails when the span gets a new one. + const { hostContext, rest } = splitHostContextAttributes(segmentSpan!.attributes); + expect(rest).toEqual({ + onSpanCreated: { type: 'string', value: 'yes' }, + 'onSpanCreated.reqUrl': { type: 'string', value: '/test' }, + 'onSpanCreated.reqMethod': { type: 'string', value: 'GET' }, + 'onSpanCreated.resUrl': { type: 'string', value: '/test' }, + 'onSpanCreated.resMethod': { type: 'string', value: 'GET' }, + 'onSpanEnd.reqUrl': { type: 'string', value: '/test' }, + 'onSpanEnd.resStatusCode': { type: 'integer', value: 200 }, + [SENTRY_OP]: { type: 'string', value: HTTP_SERVER }, + [SENTRY_ORIGIN]: { type: 'string', value: 'auto.http.http_server' }, + [SENTRY_KIND]: { type: 'string', value: 'server' }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpan!.span_id }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'GET /test' }, + [SENTRY_SEGMENT_NAME_SOURCE]: { type: 'string', value: 'route' }, + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, + [SENTRY_IS_LOCALHOST]: { type: 'boolean', value: true }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_RELEASE]: { type: 'string', value: '1.0' }, + [SENTRY_SDK_NAME]: { type: 'string', value: EXPECTED_SDK_NAME }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_SDK_INTEGRATIONS]: { type: 'array', value: expect.arrayContaining(['Http']) }, + [HTTP_REQUEST_METHOD]: { type: 'string', value: 'GET' }, + [HTTP_ROUTE]: { type: 'string', value: '/test' }, + [HTTP_RESPONSE_STATUS_CODE]: { type: 'integer', value: 200 }, + [HTTP_RESPONSE_STATUS_TEXT]: { type: 'string', value: 'OK' }, + [URL_FULL]: { type: 'string', value: expect.stringMatching(/^http:\/\/localhost:\d+\/test$/) }, + [URL_PATH]: { type: 'string', value: '/test' }, + [URL_SCHEME]: { type: 'string', value: 'http' }, + [SERVER_ADDRESS]: { type: 'string', value: 'localhost' }, + [SERVER_PORT]: { type: 'integer', value: expect.any(Number) }, + [CLIENT_ADDRESS]: { type: 'string', value: expect.stringMatching(LOOPBACK_ADDRESS) }, + [CLIENT_PORT]: { type: 'integer', value: expect.any(Number) }, + [USER_IP_ADDRESS]: { type: 'string', value: expect.stringMatching(LOOPBACK_ADDRESS) }, + [NETWORK_LOCAL_ADDRESS]: { type: 'string', value: expect.stringMatching(LOOPBACK_ADDRESS) }, + [NETWORK_LOCAL_PORT]: { type: 'integer', value: expect.any(Number) }, + [NETWORK_PEER_ADDRESS]: { type: 'string', value: expect.stringMatching(LOOPBACK_ADDRESS) }, + [NETWORK_PEER_PORT]: { type: 'integer', value: expect.any(Number) }, + [NETWORK_PROTOCOL_NAME]: { type: 'string', value: 'http' }, + [NETWORK_PROTOCOL_VERSION]: { type: 'string', value: '1.1' }, + [NETWORK_TRANSPORT]: { type: 'string', value: 'tcp' }, + [USER_AGENT_ORIGINAL]: { type: 'string', value: 'node' }, + 'http.request.header.accept': { type: 'array', value: ['*/*'] }, + 'http.request.header.accept-encoding': { type: 'array', value: ['gzip, deflate'] }, + 'http.request.header.accept-language': { type: 'array', value: ['*'] }, + 'http.request.header.connection': { type: 'array', value: ['keep-alive'] }, + 'http.request.header.host': { type: 'array', value: [expect.stringMatching(/^localhost:\d+$/)] }, + 'http.request.header.sec-fetch-mode': { type: 'array', value: ['cors'] }, + 'http.request.header.user-agent': { type: 'array', value: ['node'] }, + }); + for (const attribute of Object.values(hostContext)) { + expect(attribute).toEqual({ type: expect.any(String), value: expect.anything() }); + } }, }) .start(); @@ -57,7 +156,7 @@ describe('httpIntegration', () => { describe('user callbacks that throw', () => { createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument-throwing-callbacks.mjs', (createRunner, test) => { - test('still handles requests and sends the span when `onSpanCreated` and `ignoreIncomingRequests` throw', async () => { + test('still handles requests and sends the span when `onSpanCreated`, `onSpanEnd` and `ignoreIncomingRequests` throw', async () => { const runner = createRunner() .expect({ span: { diff --git a/dev-packages/node-integration-tests/suites/tracing/kafkajs/test.ts b/dev-packages/node-integration-tests/suites/tracing/kafkajs/test.ts index 8593a1f124ff..cd814ff541d7 100644 --- a/dev-packages/node-integration-tests/suites/tracing/kafkajs/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/kafkajs/test.ts @@ -2,8 +2,8 @@ import type { TransactionEvent } from '@sentry/core'; import { afterAll, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; -const producerOrigin = 'auto.kafkajs.producer'; -const consumerOrigin = 'auto.kafkajs.consumer'; +const producerOrigin = 'auto.queue.kafkajs.producer'; +const consumerOrigin = 'auto.queue.kafkajs.consumer'; describeWithDockerCompose('kafkajs', { workingDirectory: [__dirname] }, () => { afterAll(() => { diff --git a/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/instrument.mjs new file mode 100644 index 000000000000..d06a0abfa862 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/instrument.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + // A meta-framework answers the tunnel requests, Node only drops their server spans. + integrations: [Sentry.serveTunnelIntegration()], + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/server.mjs b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/server.mjs new file mode 100644 index 000000000000..ee4a7683d045 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/server.mjs @@ -0,0 +1,10 @@ +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import http from 'http'; + +const server = http.createServer((_request, response) => { + response.end('app'); +}); + +server.listen(0, () => { + sendPortToRunner(server.address().port); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/test.ts b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/test.ts new file mode 100644 index 000000000000..1504ba345677 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/test.ts @@ -0,0 +1,29 @@ +import { URL_PATH } from '@sentry/conventions/attributes'; +import { getTunnelPath } from '@sentry/core'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('managed tunnel', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + test('drops the server span of tunnel requests', async () => { + const runner = createRunner() + .expect({ + span: container => { + // Spans flush in order, so a span for the tunnel request would arrive in this container or before it. + const segmentPaths = container.items.filter(item => item.is_segment).map(item => item.attributes[URL_PATH]); + expect(segmentPaths).toEqual([{ type: 'string', value: '/after-tunnel' }]); + }, + }) + .start(); + + await runner.makeRequest('post', getTunnelPath('https://public@dsn.ingest.sentry.io/1337')!); + await runner.makeRequest('get', '/after-tunnel'); + + await runner.completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/scenario.mjs index df34763d12c3..bb84084ecfa1 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/scenario.mjs @@ -12,6 +12,9 @@ class MockOpenAIToolCalls { // If stream is requested, return an async generator if (params.stream) { + if (params.model === 'gemini-compat') { + return this._createGeminiStyleToolCallsStream(params); + } return this._createChatCompletionToolCallsStream(params); } @@ -139,6 +142,38 @@ class MockOpenAIToolCalls { }; } + // Gemini's OpenAI-compatible endpoint streams whole, parallel tool calls without an `index` + async *_createGeminiStyleToolCallsStream(params) { + yield { + id: 'chatcmpl-stream-gemini-tools-123', + object: 'chat.completion.chunk', + created: 1677652310, + model: params.model, + choices: [ + { + index: 0, + delta: { + role: 'assistant', + tool_calls: [ + { + id: 'call_paris', + type: 'function', + function: { name: 'get_weather', arguments: '{"latitude":48.8566,"longitude":2.3522}' }, + }, + { + id: 'call_london', + type: 'function', + function: { name: 'get_weather', arguments: '{"latitude":51.5072,"longitude":-0.1276}' }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + usage: { prompt_tokens: 15, completion_tokens: 25, total_tokens: 40 }, + }; + } + // Create a mock streaming response for responses API with tool calls async *_createResponsesApiToolCallsStream(params) { const responseId = 'resp_stream_tools_789'; @@ -275,6 +310,15 @@ async function run() { }); for await (const chunk of stream1) void chunk; + // Test 2b: Chat completion streaming whole parallel tool calls without an index (Gemini style) + const geminiStream = await client.chat.completions.create({ + model: 'gemini-compat', + messages: [message], + tools: [weatherTool], + stream: true, + }); + for await (const chunk of geminiStream) void chunk; + // Test 3: Responses API with tools (non-streaming) await client.responses.create({ model: 'gpt-4', diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts index 59473fcea8fe..cf5e050605da 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts @@ -66,6 +66,25 @@ describe('OpenAI Tool Calls integration', () => { }, ]); + const CHAT_GEMINI_STREAM_TOOL_CALLS = JSON.stringify([ + { + id: 'call_paris', + type: 'function', + function: { + name: 'get_weather', + arguments: '{"latitude":48.8566,"longitude":2.3522}', + }, + }, + { + id: 'call_london', + type: 'function', + function: { + name: 'get_weather', + arguments: '{"latitude":51.5072,"longitude":-0.1276}', + }, + }, + ]); + const RESPONSES_TOOL_CALLS = JSON.stringify([ { type: 'function_call', @@ -84,7 +103,7 @@ describe('OpenAI Tool Calls integration', () => { const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.openai'); - expect(spans).toHaveLength(4); + expect(spans).toHaveLength(5); const chatToolsSpan = spans.find( span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-tools-123', ); @@ -193,6 +212,12 @@ describe('OpenAI Tool Calls integration', () => { value: 40, }); + const geminiStreamSpan = spans.find( + span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-gemini-tools-123', + ); + expect(geminiStreamSpan).toBeDefined(); + expect(geminiStreamSpan!.attributes[GEN_AI_RESPONSE_TOOL_CALLS]).toBeUndefined(); + const responsesToolsSpan = spans.find( span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_tools_789', ); @@ -318,7 +343,7 @@ describe('OpenAI Tool Calls integration', () => { const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.openai'); - expect(spans).toHaveLength(4); + expect(spans).toHaveLength(5); const chatToolsSpan = spans.find( span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-tools-123', ); @@ -452,6 +477,15 @@ describe('OpenAI Tool Calls integration', () => { value: 40, }); + const geminiStreamSpan = spans.find( + span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-gemini-tools-123', + ); + expect(geminiStreamSpan).toBeDefined(); + expect(geminiStreamSpan!.attributes[GEN_AI_RESPONSE_TOOL_CALLS]).toEqual({ + type: 'string', + value: CHAT_GEMINI_STREAM_TOOL_CALLS, + }); + const responsesToolsSpan = spans.find( span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_tools_789', ); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-chat.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-chat.mjs index 6b8bc76b582b..84e2c2a0bddc 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-chat.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-chat.mjs @@ -22,35 +22,74 @@ function startMockServer() { res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); - const chunks = [ + // Gemini's OpenAI-compatible endpoint streams whole, parallel tool calls without an `index` + const geminiToolCallChunks = [ { - id: 'chatcmpl-stream-123', + id: 'chatcmpl-stream-gemini-456', object: 'chat.completion.chunk', - created: 1677652300, + created: 1677652305, model: model, - choices: [{ delta: { role: 'assistant', content: '' }, index: 0 }], - }, - { - id: 'chatcmpl-stream-123', - object: 'chat.completion.chunk', - created: 1677652300, - model: model, - choices: [{ delta: { content: 'Hello from OpenAI streaming!' }, index: 0 }], - }, - { - id: 'chatcmpl-stream-123', - object: 'chat.completion.chunk', - created: 1677652300, - model: model, - choices: [{ delta: {}, index: 0, finish_reason: 'stop' }], + choices: [ + { + index: 0, + delta: { + role: 'assistant', + tool_calls: [ + { + id: 'call_paris', + type: 'function', + function: { name: 'get_weather', arguments: '{"city":"Paris"}' }, + }, + { + id: 'call_london', + type: 'function', + function: { name: 'get_weather', arguments: '{"city":"London"}' }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], usage: { - prompt_tokens: 12, - completion_tokens: 18, - total_tokens: 30, + prompt_tokens: 15, + completion_tokens: 25, + total_tokens: 40, }, }, ]; + const chunks = + model === 'gemini-compat' + ? geminiToolCallChunks + : [ + { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model: model, + choices: [{ delta: { role: 'assistant', content: '' }, index: 0 }], + }, + { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model: model, + choices: [{ delta: { content: 'Hello from OpenAI streaming!' }, index: 0 }], + }, + { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model: model, + choices: [{ delta: {}, index: 0, finish_reason: 'stop' }], + usage: { + prompt_tokens: 12, + completion_tokens: 18, + total_tokens: 30, + }, + }, + ]; + chunks.forEach((chunk, index) => { setTimeout(() => { res.write(`data: ${JSON.stringify(chunk)}\n\n`); @@ -243,6 +282,17 @@ async function run() { void chunk; // Prevent unused variable warning } + // Streaming whole parallel tool calls without an index (Gemini style) + const geminiStream = await client.chat.completions.create({ + model: 'gemini-compat', + messages: [{ role: 'user', content: 'Weather in Paris and London?' }], + stream: true, + }); + + for await (const chunk of geminiStream) { + void chunk; + } + // Fifth test: responses API streaming const stream2 = await client.responses.create({ model: 'gpt-4', diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts index 4db70ae10643..41b57d713565 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -13,6 +13,7 @@ import { GEN_AI_RESPONSE_MODEL, GEN_AI_RESPONSE_STREAMING, GEN_AI_RESPONSE_TEXT, + GEN_AI_RESPONSE_TOOL_CALLS, GEN_AI_SYSTEM_INSTRUCTIONS, GEN_AI_USAGE_INPUT_TOKENS, GEN_AI_USAGE_OUTPUT_TOKENS, @@ -41,7 +42,7 @@ describe('OpenAI integration', () => { const allSpans = await spansPromise; const spans = allSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.openai'); - expect(spans).toHaveLength(8); + expect(spans).toHaveLength(9); const chatCompletionSpan = spans.find( span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123' && @@ -173,6 +174,12 @@ describe('OpenAI integration', () => { value: 'error-model', }); + const geminiStreamSpan = spans.find( + span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-gemini-456', + ); + expect(geminiStreamSpan).toBeDefined(); + expect(geminiStreamSpan!.attributes[GEN_AI_RESPONSE_TOOL_CALLS]).toBeUndefined(); + const streamingChatCompletionSpan = spans.find( span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-123', ); @@ -444,7 +451,7 @@ describe('OpenAI integration', () => { const allSpans = await spansPromise; const spans = allSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.openai'); - expect(spans).toHaveLength(8); + expect(spans).toHaveLength(9); const chatCompletionSpan = spans.find( span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123' && @@ -600,6 +607,18 @@ describe('OpenAI integration', () => { value: '[{"role":"user","content":"This will fail"}]', }); + const geminiStreamSpan = spans.find( + span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-gemini-456', + ); + expect(geminiStreamSpan).toBeDefined(); + expect(geminiStreamSpan!.attributes[GEN_AI_RESPONSE_TOOL_CALLS]).toEqual({ + type: 'string', + value: JSON.stringify([ + { id: 'call_paris', type: 'function', function: { name: 'get_weather', arguments: '{"city":"Paris"}' } }, + { id: 'call_london', type: 'function', function: { name: 'get_weather', arguments: '{"city":"London"}' } }, + ]), + }); + const streamingChatCompletionSpan = spans.find( span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-123', ); @@ -902,7 +921,7 @@ describe('OpenAI integration', () => { const allSpans = await spansPromise; const spans = allSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.openai'); - expect(spans).toHaveLength(8); + expect(spans).toHaveLength(9); const chatCompletionSpan = spans.find( span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123' && @@ -918,6 +937,18 @@ describe('OpenAI integration', () => { value: expect.any(String), }); + const geminiStreamSpan = spans.find( + span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-gemini-456', + ); + expect(geminiStreamSpan).toBeDefined(); + expect(geminiStreamSpan!.attributes[GEN_AI_RESPONSE_TOOL_CALLS]).toEqual({ + type: 'string', + value: JSON.stringify([ + { id: 'call_paris', type: 'function', function: { name: 'get_weather', arguments: '{"city":"Paris"}' } }, + { id: 'call_london', type: 'function', function: { name: 'get_weather', arguments: '{"city":"London"}' } }, + ]), + }); + const streamingChatCompletionSpan = spans.find( span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-123', ); diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/test.ts b/dev-packages/node-integration-tests/suites/tracing/pi-durable/test.ts index 068b389c60d2..7bfd7947bb9f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/pi-durable/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/test.ts @@ -36,6 +36,9 @@ const PI_DURABLE_DEPENDENCIES = { }, }; +// The provider session id that pi-durable stores with each conversation, a UUIDv7. +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + const CONTENT_ATTRIBUTES = [ GEN_AI_INPUT_MESSAGES, GEN_AI_OUTPUT_MESSAGES, @@ -84,9 +87,7 @@ describe.runIf(supports({ min: 22 }))('pi-durable integration', () => { expect(agent.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.pi_durable'); expect(agent.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('invoke_agent'); const conversationId = agent.attributes[GEN_AI_CONVERSATION_ID]?.value; - // The root conversation's id is `1` in every pi-durable storage, so it is prefixed - // with a per-Harness id. - expect(conversationId).toMatch(/^[0-9a-f]{32}:1$/); + expect(conversationId).toMatch(UUID); const chats = spans .filter(span => span.name === 'chat faux-model') @@ -399,7 +400,7 @@ describe.runIf(supports({ min: 22 }))('pi-durable integration', () => { .start() .completed(); - expect(runConversationId).toMatch(/^[0-9a-f]{32}:1$/); + expect(runConversationId).toMatch(UUID); expect(compactionConversationId).toBe(runConversationId); }); }, @@ -427,13 +428,14 @@ describe.runIf(supports({ min: 22 }))('pi-durable integration', () => { expect(delegate.parent_span_id).toBe(parent.span_id); // The subagent's run is a child of the delegating call, in the same trace, on its own - // conversation of the same Harness. + // conversation. const child = spans.find(span => span.name === 'invoke_agent' && !span.is_segment)!; expect(child.parent_span_id).toBe(delegate.span_id); const parentConversation = String(parent.attributes[GEN_AI_CONVERSATION_ID]?.value); const childConversation = String(child.attributes[GEN_AI_CONVERSATION_ID]?.value); + expect(parentConversation).toMatch(UUID); + expect(childConversation).toMatch(UUID); expect(childConversation).not.toBe(parentConversation); - expect(childConversation.split(':')[0]).toBe(parentConversation.split(':')[0]); const childChat = spans.find(span => span.parent_span_id === child.span_id)!; expect(childChat.name).toBe('chat faux-model'); @@ -646,7 +648,7 @@ describe.runIf(supports({ min: 22 }))('pi-durable integration', () => { ); // The run's own conversation id, not the one the request set on its scope. const conversationId = agent.attributes[GEN_AI_CONVERSATION_ID]?.value; - expect(conversationId).toMatch(/^[0-9a-f]{32}:\d+$/); + expect(conversationId).toMatch(UUID); for (const span of spans) { expect(span.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(conversationId); expect(span.attributes['request']).toBeUndefined(); @@ -667,7 +669,7 @@ describe.runIf(supports({ min: 22 }))('pi-durable integration', () => { 'request B', ); const conversationId = agent.attributes[GEN_AI_CONVERSATION_ID]?.value; - expect(conversationId).toMatch(/^[0-9a-f]{32}:\d+$/); + expect(conversationId).toMatch(UUID); for (const span of spans) { expect(span.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(conversationId); expect(span.attributes['request']).toBeUndefined(); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sentry-requests/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sentry-requests/instrument.mjs new file mode 100644 index 000000000000..83be6e35634b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sentry-requests/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@localhost/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sentry-requests/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sentry-requests/scenario.mjs new file mode 100644 index 000000000000..5e4abdda9c92 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sentry-requests/scenario.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; + +// Another SDK in this process (e.g. the Next.js Edge Runtime SDK) sends to Sentry without suppressing our tracing +const sentryUrl = `${process.env.SERVER_URL}/api/1337/envelope/?sentry_version=7&sentry_key=public&sentry_client=other`; + +Sentry.startSpan({ name: 'test_span' }, async () => { + await fetch(sentryUrl).then(res => res.text()); + const response = await fetch(`${process.env.SERVER_URL}/api/v0`); + + await response.text(); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sentry-requests/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sentry-requests/test.ts new file mode 100644 index 000000000000..afdd01bab691 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sentry-requests/test.ts @@ -0,0 +1,25 @@ +import { SENTRY_OP, URL_FULL } from '@sentry/conventions/attributes'; +import { HTTP_CLIENT } from '@sentry/conventions/op'; +import { createTestServer } from '@sentry-internal/test-utils'; +import { describe, expect } from 'vitest'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; + +describe('outgoing fetch requests to Sentry', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('does not create spans for requests to Sentry', async () => { + const [SERVER_URL, closeTestServer] = await createTestServer().start(); + + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + span: container => { + const requestSpans = container.items.filter(span => span.attributes[SENTRY_OP]?.value === HTTP_CLIENT); + expect(requestSpans.map(span => span.attributes[URL_FULL]?.value)).toEqual([`${SERVER_URL}/api/v0`]); + }, + }) + .start() + .completed(); + closeTestServer(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-sentry-requests/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/requests/http-sentry-requests/instrument.mjs new file mode 100644 index 000000000000..83be6e35634b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-sentry-requests/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@localhost/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-sentry-requests/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/requests/http-sentry-requests/scenario.mjs new file mode 100644 index 000000000000..99666e006794 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-sentry-requests/scenario.mjs @@ -0,0 +1,25 @@ +import * as Sentry from '@sentry/node'; +import * as http from 'http'; + +// Another SDK in this process (e.g. the Next.js Edge Runtime SDK) sends to Sentry without suppressing our tracing +const sentryUrl = `${process.env.SERVER_URL}/api/1337/envelope/?sentry_version=7&sentry_key=public&sentry_client=other`; + +Sentry.startSpan({ name: 'test_span' }, async () => { + await makeHttpRequest(sentryUrl); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); +}); + +function makeHttpRequest(url) { + return new Promise(resolve => { + http + .request(url, httpRes => { + httpRes.on('data', () => { + // we don't care about data + }); + httpRes.on('end', () => { + resolve(); + }); + }) + .end(); + }); +} diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-sentry-requests/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-sentry-requests/test.ts new file mode 100644 index 000000000000..f2c0c4d106ec --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-sentry-requests/test.ts @@ -0,0 +1,28 @@ +import { SENTRY_OP, URL_FULL } from '@sentry/conventions/attributes'; +import { HTTP_CLIENT } from '@sentry/conventions/op'; +import { createTestServer } from '@sentry-internal/test-utils'; +import { describe, expect } from 'vitest'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; +import { supports } from '../../../../utils'; + +// Bun before 1.4 does not instrument outgoing `node:http` requests. +// See https://github.com/getsentry/sentry-javascript/issues/23881 +describe.runIf(supports({ bunMin: '1.4.0' }))('outgoing http requests to Sentry', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('does not create spans for requests to Sentry', async () => { + const [SERVER_URL, closeTestServer] = await createTestServer().start(); + + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + span: container => { + const requestSpans = container.items.filter(span => span.attributes[SENTRY_OP]?.value === HTTP_CLIENT); + expect(requestSpans.map(span => span.attributes[URL_FULL]?.value)).toEqual([`${SERVER_URL}/api/v0`]); + }, + }) + .start() + .completed(); + closeTestServer(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/together-ai/v1/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/together-ai/v1/instrument.mjs new file mode 100644 index 000000000000..cc192fb89834 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/together-ai/v1/instrument.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + dataCollection: { genAI: { inputs: false, outputs: false } }, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/together-ai/v1/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/together-ai/v1/scenario.mjs new file mode 100644 index 000000000000..c52720e3de60 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/together-ai/v1/scenario.mjs @@ -0,0 +1,115 @@ +import Together from 'together-ai'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/chat/completions', (req, res) => { + const { model, stream } = req.body; + + // error-model returns 404 (not retried by the SDK) so the span records an error + if (model === 'error-model') { + res.status(404).set('x-request-id', 'mock-request-123').end('Model not found'); + return; + } + + if (stream) { + res.setHeader('Content-Type', 'text/event-stream'); + const chunks = [ + { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [{ index: 0, delta: { role: 'assistant', content: 'Hello from Together streaming!' } }], + }, + { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 12, completion_tokens: 18, total_tokens: 30 }, + }, + ]; + for (const chunk of chunks) { + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + } + res.end('data: [DONE]\n\n'); + return; + } + + res.send({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model, + choices: [ + { index: 0, message: { role: 'assistant', content: 'Hello from Together mock!' }, finish_reason: 'stop' }, + ], + usage: { prompt_tokens: 10, completion_tokens: 15, total_tokens: 25 }, + }); + }); + + app.post('/v1/embeddings', (req, res) => { + res.send({ + id: 'embd-mock123', + object: 'list', + model: req.body.model, + data: [{ object: 'embedding', index: 0, embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 8, total_tokens: 8 }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new Together({ + apiKey: 'mock-api-key', + baseURL: `http://localhost:${server.address().port}`, + }); + + await client.chat.completions.create({ + model: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', + messages: [{ role: 'user', content: 'What is the capital of France?' }], + }); + + try { + await client.chat.completions.create({ + model: 'error-model', + messages: [{ role: 'user', content: 'This will fail' }], + }); + } catch { + // expected + } + + const stream = await client.chat.completions.create({ + model: 'meta-llama/Llama-3.1-8B-Instruct-Turbo', + messages: [{ role: 'user', content: 'Tell me about streaming' }], + stream: true, + }); + for await (const chunk of stream) { + void chunk; + } + + await client.embeddings.create({ + model: 'togethercomputer/m2-bert-80M-8k-retrieval', + input: 'Embedding test!', + }); + }); + + await Sentry.flush(2000); + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/together-ai/v1/test.ts b/dev-packages/node-integration-tests/suites/tracing/together-ai/v1/test.ts new file mode 100644 index 000000000000..c48e54e7b561 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/together-ai/v1/test.ts @@ -0,0 +1,65 @@ +import { + GEN_AI_OPERATION_NAME, + GEN_AI_PROVIDER_NAME, + GEN_AI_RESPONSE_ID, + GEN_AI_RESPONSE_STREAMING, + GEN_AI_USAGE_TOTAL_TOKENS, + SENTRY_OP, + SENTRY_ORIGIN, +} from '@sentry/conventions/attributes'; +import { GEN_AI_CHAT, GEN_AI_EMBEDDINGS } from '@sentry/conventions/op'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +const ORIGIN = 'auto.ai.together_ai'; + +// together-ai 1.x defines `create` on `BaseCompletions`/`BaseEmbeddings` (`Completions`/`Embeddings` are now +// empty subclasses), which needs its own orchestrion config entry. Attribute extraction is version-independent +// and covered by the suite pinned to 0.x; this one guards that each instrumented method still gets a span. +describe('Together integration (1.x)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('auto-instruments chat completions and embeddings on together-ai 1.x', async () => { + const runner = createRunner().ignore('event'); + const spansPromise = runner.collectStreamedSpansUntilSegment('main'); + + await runner.start().completed(); + + const spans = (await spansPromise).filter(span => span.attributes[SENTRY_ORIGIN]?.value === ORIGIN); + expect(spans.map(span => span.name).sort()).toEqual([ + 'chat error-model', + 'chat meta-llama/Llama-3.1-8B-Instruct-Turbo', + 'chat meta-llama/Llama-3.3-70B-Instruct-Turbo', + 'embeddings togethercomputer/m2-bert-80M-8k-retrieval', + ]); + for (const span of spans) { + expect(span.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe('together_ai'); + } + + const chatSpan = spans.find(span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123'); + expect(chatSpan!.status).toBe('ok'); + expect(chatSpan!.attributes[SENTRY_OP]?.value).toBe(GEN_AI_CHAT); + expect(chatSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(25); + + const errorSpan = spans.find(span => span.name === 'chat error-model'); + expect(errorSpan!.status).toBe('error'); + + const streamSpan = spans.find(span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-123'); + expect(streamSpan!.attributes[GEN_AI_RESPONSE_STREAMING]?.value).toBe(true); + expect(streamSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(30); + + const embeddingsSpan = spans.find(span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'embd-mock123'); + expect(embeddingsSpan!.attributes[SENTRY_OP]?.value).toBe(GEN_AI_EMBEDDINGS); + expect(embeddingsSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('embeddings'); + }); + }, + { additionalDependencies: { 'together-ai': '1.0.0' } }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/test.ts index 99604cb8a599..01d9aa1450e3 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/test.ts @@ -25,7 +25,7 @@ import { GEN_AI_TOOL_CALL_ID_ATTRIBUTE } from '../../../../../../packages/server import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; import { getStringAttributeValue } from '../../../../utils'; -const expectedOrigin = 'auto.vercelai.channel'; +const expectedOrigin = 'auto.ai.vercelai'; const toolCallArgs = '{\\"location\\":\\"San Francisco\\"}'; describe('Vercel AI integration (v4)', () => { diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts index b608a1f717fc..7c82eb3f9a91 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts @@ -39,7 +39,7 @@ describe('Vercel AI integration (v5)', () => { const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); expect(spans).toHaveLength(7); const firstInvokeAgentSpan = spans.find( @@ -131,7 +131,7 @@ describe('Vercel AI integration (v5)', () => { const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); expect(spans).toHaveLength(7); const firstInvokeAgentSpan = spans.find( @@ -247,7 +247,7 @@ describe('Vercel AI integration (v5)', () => { .completed(); const segment = receivedSpans.find(span => span.is_segment && span.name === 'main'); - const spans = receivedSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel'); + const spans = receivedSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai'); expect(spans).toHaveLength(3); const invokeAgentSpan = spans.find(span => span.name === 'invoke_agent'); expect(invokeAgentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent'); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts index 077d96e0d9bb..ea89bf21ddaf 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts @@ -40,7 +40,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe cleanupChildProcesses(); }); - const expectedOrigin = 'auto.vercelai.channel'; + const expectedOrigin = 'auto.ai.vercelai'; // We only run this in ESM and CJS to verify full support // Other suites we only run in ESM to simplify the test setup @@ -56,7 +56,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); expect(spans).toHaveLength(7); const firstInvokeAgentSpan = spans.find( @@ -309,7 +309,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); expect(spans).toHaveLength(7); const firstInvokeAgentSpan = spans.find( @@ -539,7 +539,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe .completed(); const segment = receivedSpans.find(span => span.is_segment && span.name === 'main'); - const spans = receivedSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel'); + const spans = receivedSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai'); expect(spans).toHaveLength(3); const invokeAgentSpan = spans.find(span => span.name === 'invoke_agent')!; expect(invokeAgentSpan!.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: expectedOrigin }); @@ -611,7 +611,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); expect(spans).toHaveLength(7); const invokeAgentSpans = spans.filter( @@ -651,7 +651,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); expect(spans).toHaveLength(4); const invokeAgentSpan = spans.find(span => span.name === 'invoke_agent weather_agent')!; @@ -707,7 +707,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); const invokeAgents = spans.filter(span => span.attributes[SENTRY_OP]?.value === 'gen_ai.invoke_agent'); const generateContents = spans.filter( @@ -756,7 +756,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); const invokeAgents = spans.filter(span => span.attributes[SENTRY_OP]?.value === 'gen_ai.invoke_agent'); const generateContents = spans.filter( @@ -802,7 +802,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); const invokeAgent = spans.find(span => span.attributes[SENTRY_OP]?.value === 'gen_ai.invoke_agent')!; expect(invokeAgent.attributes['vercel.ai.operationId']?.value).toBe('ai.streamText'); @@ -857,7 +857,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); const invokeAgent = spans.find(span => span.attributes[SENTRY_OP]?.value === 'gen_ai.invoke_agent')!; expect(invokeAgent.status).toBe('ok'); @@ -921,7 +921,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); const invokeAgent = spans.find(span => span.attributes[SENTRY_OP]?.value === 'gen_ai.invoke_agent')!; expect(invokeAgent.status).toBe('ok'); @@ -965,7 +965,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); // The model throws, so the operation rejects. The spans must still be *finished* (and // therefore present in the transaction) with an error status — not left open. @@ -1001,7 +1001,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); const generateContent = spans.find( span => span.attributes[SENTRY_OP]?.value === 'gen_ai.generate_content', @@ -1108,7 +1108,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); const generateContent = spans.find( span => span.attributes[SENTRY_OP]?.value === 'gen_ai.generate_content', @@ -1151,7 +1151,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); const embedSpan = spans.find( @@ -1203,7 +1203,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); const invokeAgentSpan = spans.find(span => span.name === 'invoke_agent'); @@ -1293,7 +1293,7 @@ describe('Vercel AI integration experimental_decide', () => { const decideSpan = container.items.find(span => span.attributes[SENTRY_OP]?.value === GEN_AI_DECIDE)!; expect(decideSpan.name).toBe('decide typesafe-ai/jev'); expect(decideSpan.status).toBe('ok'); - expect(decideSpan.attributes[SENTRY_ORIGIN]?.value).toBe('auto.vercelai.channel'); + expect(decideSpan.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.vercelai'); expect(decideSpan.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('decide'); expect(decideSpan.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe('gateway'); expect(decideSpan.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('typesafe-ai/jev'); diff --git a/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/express.mjs b/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/express.mjs new file mode 100644 index 000000000000..91f1690d265c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/express.mjs @@ -0,0 +1,39 @@ +import * as Sentry from '@sentry/node'; +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; + +Sentry.init({ dsn: process.env.SENTRY_DSN }); + +const app = express(); + +app.get('/dsn', (_request, response) => { + response.send(process.env.SENTRY_DSN); +}); + +// The test's last request, so the runner only stops the scenario after every check. +app.get('/done', (_request, response) => { + Sentry.captureMessage('done'); + response.send('ok'); +}); + +// The tunnel posts `application/x-sentry-envelope`, which `express.json()` leaves alone. +app.use(express.json()); + +// Middleware style +app.use((request, response, next) => Sentry.handleTunnelRequestIfMatched(request, response) ?? next()); + +// Route style +app.post('/route-tunnel', (request, response) => Sentry.handleTunnelRequest(request, response)); + +// A parser that reads every body leaves the tunnel nothing to forward. +app.post('/parsed-tunnel', express.raw({ type: '*/*' }), (request, response) => + Sentry.handleTunnelRequest(request, response), +); + +app.use((_request, response) => { + response.send('app'); +}); + +const server = app.listen(0, () => { + sendPortToRunner(server.address().port); +}); diff --git a/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/fastify.mjs b/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/fastify.mjs new file mode 100644 index 000000000000..560a891f5f9f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/fastify.mjs @@ -0,0 +1,27 @@ +import * as Sentry from '@sentry/node'; +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import Fastify from 'fastify'; + +Sentry.init({ dsn: process.env.SENTRY_DSN }); + +const app = Fastify(); + +app.addHook('onRequest', async (request, reply) => { + const tunnelResponse = Sentry.handleTunnelRequestIfMatched(request.raw, reply.raw); + if (tunnelResponse) { + await tunnelResponse; + return reply; + } +}); + +app.get('/dsn', async () => process.env.SENTRY_DSN); + +// The test's last request, so the runner only stops the scenario after every check. +app.get('/done', async () => { + Sentry.captureMessage('done'); + return 'ok'; +}); + +app.listen({ port: 0 }).then(() => { + sendPortToRunner(app.server.address().port); +}); diff --git a/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/http.mjs b/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/http.mjs new file mode 100644 index 000000000000..ec147c813a9e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/http.mjs @@ -0,0 +1,27 @@ +import * as Sentry from '@sentry/node'; +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import http from 'http'; + +Sentry.init({ dsn: process.env.SENTRY_DSN }); + +const server = http.createServer((request, response) => { + if (request.url === '/dsn') { + response.end(process.env.SENTRY_DSN); + return; + } + + // The test's last request, so the runner only stops the scenario after every check. + if (request.url === '/done') { + Sentry.captureMessage('done'); + response.end('ok'); + return; + } + + if (!Sentry.handleTunnelRequestIfMatched(request, response)) { + response.end('app'); + } +}); + +server.listen(0, () => { + sendPortToRunner(server.address().port); +}); diff --git a/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/http2.mjs b/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/http2.mjs new file mode 100644 index 000000000000..b036aa7c850d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/http2.mjs @@ -0,0 +1,27 @@ +import * as Sentry from '@sentry/node'; +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import http2 from 'http2'; + +Sentry.init({ dsn: process.env.SENTRY_DSN }); + +const server = http2.createServer((request, response) => { + if (request.url === '/dsn') { + response.end(process.env.SENTRY_DSN); + return; + } + + // The test's last request, so the runner only stops the scenario after every check. + if (request.url === '/done') { + Sentry.captureMessage('done'); + response.end('ok'); + return; + } + + if (!Sentry.handleTunnelRequestIfMatched(request, response)) { + response.end('app'); + } +}); + +server.listen(0, () => { + sendPortToRunner(server.address().port); +}); diff --git a/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/koa.mjs b/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/koa.mjs new file mode 100644 index 000000000000..70f6c1a367b7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/koa.mjs @@ -0,0 +1,29 @@ +import * as Sentry from '@sentry/node'; +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import Koa from 'koa'; + +Sentry.init({ dsn: process.env.SENTRY_DSN }); + +const app = new Koa(); + +app.use(async (ctx, next) => { + const tunnelResponse = Sentry.handleTunnelRequestIfMatched(ctx.req, ctx.res); + if (tunnelResponse) { + await tunnelResponse; + return; + } + + await next(); +}); + +app.use(ctx => { + // `/done` is the test's last request, so the runner only stops the scenario after every check. + if (ctx.path === '/done') { + Sentry.captureMessage('done'); + } + ctx.body = ctx.path === '/dsn' ? process.env.SENTRY_DSN : 'app'; +}); + +const server = app.listen(0, () => { + sendPortToRunner(server.address().port); +}); diff --git a/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/test.ts b/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/test.ts new file mode 100644 index 000000000000..a9a4fb8387e8 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/test.ts @@ -0,0 +1,195 @@ +import { createEnvelope, getTunnelPath, serializeEnvelope, uuid4 } from '@sentry/core'; +import * as http from 'node:http'; +import * as http2 from 'node:http2'; +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +type Runner = ReturnType['start']>; + +/** + * An event envelope as the browser SDK sends it through the tunnel, for the scenario's DSN. + */ +async function browserEnvelope(runner: Runner, message: string): Promise<{ tunnelPath: string; envelope: string }> { + const dsn = (await runner.makeRequest('get', '/dsn'))!; + const eventId = uuid4(); + const envelope = serializeEnvelope( + createEnvelope({ dsn, event_id: eventId, sent_at: new Date().toISOString() }, [ + [{ type: 'event' }, { event_id: eventId, message }], + ]), + ) as string; + + return { tunnelPath: getTunnelPath(dsn)!, envelope }; +} + +/** + * POSTs to the scenario server and returns the status and body, also for error responses. + */ +async function post(runner: Runner, path: string, body: string): Promise<{ status: number; text: string }> { + const response = await fetch(`http://localhost:${runner.getPort()}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/x-sentry-envelope' }, + body, + }); + + return { status: response.status, text: await response.text() }; +} + +/** + * POSTs a chunked body, without `Content-Length`. + */ +function postChunked(runner: Runner, path: string, body: string): Promise { + return new Promise((resolve, reject) => { + const request = http.request({ port: runner.getPort(), path, method: 'POST' }, response => { + response.resume(); + response.on('end', () => resolve(response.statusCode)); + }); + request.on('error', reject); + request.write(body.slice(0, 5)); + request.end(body.slice(5)); + }); +} + +/** + * Waits for the scenario server's port, which `makeRequest` does for HTTP/1 requests. + */ +async function waitForPort(runner: Runner): Promise { + for (let i = 0; i < 200 && !runner.getPort(); i++) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + + return runner.getPort()!; +} + +/** + * Makes an HTTP/2 request and returns the status and body. + */ +function http2Request( + client: http2.ClientHttp2Session, + method: string, + path: string, + body?: string, +): Promise<{ status: unknown; body: string }> { + return new Promise(resolve => { + const request = client.request({ + ':method': method, + ':path': path, + ...(body && { 'content-type': 'application/x-sentry-envelope', 'content-length': Buffer.byteLength(body) }), + }); + let status: unknown; + let responseBody = ''; + request.on('response', headers => (status = headers[':status'])); + request.on('data', chunk => (responseBody += chunk)); + request.on('end', () => resolve({ status, body: responseBody })); + request.end(body); + }); +} + +describe('wiring the managed tunnel by hand', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('forwards from node:http with handleTunnelRequestIfMatched and leaves the rest to the app', async () => { + const runner = createRunner(__dirname, 'http.mjs') + .withMockSentryServer() + .expect({ event: { message: 'via node:http' } }) + .expect({ event: { message: 'done' } }) + .start(); + const { tunnelPath, envelope } = await browserEnvelope(runner, 'via node:http'); + + expect(await post(runner, tunnelPath, envelope)).toEqual({ status: 200, text: expect.any(String) }); + expect(await post(runner, '/api', envelope)).toEqual({ status: 200, text: 'app' }); + + await runner.makeRequest('get', '/done'); + await runner.completed(); + }); + + test('rejects a chunked upload without Content-Length with a 411 and forwards nothing', async () => { + const runner = createRunner(__dirname, 'http.mjs') + .withMockSentryServer() + .expect({ event: { message: 'after the chunked upload' } }) + .expect({ event: { message: 'done' } }) + .start(); + const chunked = await browserEnvelope(runner, 'chunked upload'); + const after = await browserEnvelope(runner, 'after the chunked upload'); + + expect(await postChunked(runner, chunked.tunnelPath, chunked.envelope)).toBe(411); + expect((await post(runner, after.tunnelPath, after.envelope)).status).toBe(200); + + await runner.makeRequest('get', '/done'); + await runner.completed(); + }); + + test('forwards from node:http2, whose requests carry pseudo-headers', async () => { + const runner = createRunner(__dirname, 'http2.mjs') + .withMockSentryServer() + .expect({ event: { message: 'via node:http2' } }) + .expect({ event: { message: 'done' } }) + .start(); + const client = http2.connect(`http://localhost:${await waitForPort(runner)}`); + + const dsn = (await http2Request(client, 'GET', '/dsn')).body; + const eventId = uuid4(); + const envelope = serializeEnvelope( + createEnvelope({ dsn, event_id: eventId }, [ + [{ type: 'event' }, { event_id: eventId, message: 'via node:http2' }], + ]), + ) as string; + + expect((await http2Request(client, 'POST', getTunnelPath(dsn)!, envelope)).status).toBe(200); + expect(await http2Request(client, 'POST', '/api', envelope)).toEqual({ status: 200, body: 'app' }); + + await http2Request(client, 'GET', '/done'); + client.close(); + await runner.completed(); + }); + + test('forwards from Express as middleware and as a route, and gets nothing to forward behind a catch-all parser', async () => { + const runner = createRunner(__dirname, 'express.mjs') + .withMockSentryServer() + .expect({ event: { message: 'via express middleware' } }) + .expect({ event: { message: 'via express route' } }) + .expect({ event: { message: 'done' } }) + .start(); + const middleware = await browserEnvelope(runner, 'via express middleware'); + const route = await browserEnvelope(runner, 'via express route'); + const parsed = await browserEnvelope(runner, 'behind express.raw'); + + expect((await post(runner, middleware.tunnelPath, middleware.envelope)).status).toBe(200); + expect((await post(runner, '/parsed-tunnel', parsed.envelope)).status).toBe(400); + expect((await post(runner, '/route-tunnel', route.envelope)).status).toBe(200); + expect(await post(runner, '/api', route.envelope)).toEqual({ status: 200, text: 'app' }); + + await runner.makeRequest('get', '/done'); + await runner.completed(); + }); + + test('forwards from a Fastify onRequest hook', async () => { + const runner = createRunner(__dirname, 'fastify.mjs') + .withMockSentryServer() + .expect({ event: { message: 'via fastify' } }) + .expect({ event: { message: 'done' } }) + .start(); + const { tunnelPath, envelope } = await browserEnvelope(runner, 'via fastify'); + + expect((await post(runner, tunnelPath, envelope)).status).toBe(200); + + await runner.makeRequest('get', '/done'); + await runner.completed(); + }); + + test('forwards from a Koa middleware', async () => { + const runner = createRunner(__dirname, 'koa.mjs') + .withMockSentryServer() + .expect({ event: { message: 'via koa' } }) + .expect({ event: { message: 'done' } }) + .start(); + const { tunnelPath, envelope } = await browserEnvelope(runner, 'via koa'); + + expect((await post(runner, tunnelPath, envelope)).status).toBe(200); + expect(await post(runner, '/api', envelope)).toEqual({ status: 200, text: 'app' }); + + await runner.makeRequest('get', '/done'); + await runner.completed(); + }); +}); diff --git a/dev-packages/node-integration-tests/utils/runner/createEsmAndCjsTests.ts b/dev-packages/node-integration-tests/utils/runner/createEsmAndCjsTests.ts index 60b63507ff77..30ae5c888125 100644 --- a/dev-packages/node-integration-tests/utils/runner/createEsmAndCjsTests.ts +++ b/dev-packages/node-integration-tests/utils/runner/createEsmAndCjsTests.ts @@ -1,12 +1,13 @@ -import { exec } from 'child_process'; +import { exec, execFile } from 'child_process'; import { existsSync } from 'fs'; import { cp, mkdir, readFile, rm, writeFile } from 'fs/promises'; -import { basename, join } from 'path'; +import { basename, delimiter, join } from 'path'; import { promisify } from 'util'; import { afterAll, beforeAll, test, type TestAPI } from 'vitest'; -import { CLEANUP_STEPS, createRunner } from './createRunner'; +import { CLEANUP_STEPS, createRunner, PACKAGE_ROOT } from './createRunner'; const execPromise = promisify(exec); +const execFilePromise = promisify(execFile); interface ScenarioPaths { cjs: { @@ -161,10 +162,11 @@ function setupAndCleanup( createdRunners: Set>, tmpDirPath: string, ) { - // Create tmp directory and install additionalDependencies (with retries) + // Create tmp directory and install additionalDependencies (with retries). + // Must cover all npm install attempts (see `NPM_INSTALL_TIMEOUT_MS`) plus `afterSetupCommand`. beforeAll(async () => { await createTmpDir(); - }, 120_000); + }, 200_000); // Clean up the tmp directory after both esm and cjs suites have run afterAll(async () => { @@ -267,7 +269,10 @@ function prepareTmpDir( } if (options?.afterSetupCommand) { - const env = { ...process.env, PATH: `${join(tmpDirPath, 'node_modules', '.bin')}:${process.env.PATH}` }; + // The Bun and Deno packages run these suites with their own `PATH`, which does not have the bins of + // this package (e.g. `prisma`). + const binPaths = [join(tmpDirPath, 'node_modules', '.bin'), join(PACKAGE_ROOT, 'node_modules', '.bin')]; + const env = { ...process.env, PATH: [...binPaths, process.env.PATH].join(delimiter) }; await execPromise(options.afterSetupCommand, { cwd: tmpDirPath, env }); } } @@ -329,17 +334,23 @@ function convertEsmToCjs(content: string): string { const NPM_INSTALL_MAX_RETRIES = 3; const NPM_INSTALL_RETRY_DELAY_MS = 2_000; +// npm install can stall on CI without ever failing, which would otherwise use up the whole setup hook timeout +// before a retry gets a chance to run. +const NPM_INSTALL_TIMEOUT_MS = 60_000; async function npmInstallWithRetry(cwd: string, deps: string[]): Promise { for (let attempt = 1; attempt <= NPM_INSTALL_MAX_RETRIES; attempt++) { try { - const { stdout, stderr } = await execPromise( + // Run npm without a shell, so the timeout kills npm itself rather than leaving it running next to the retry + const { stdout, stderr } = await execFilePromise( + 'npm', // We only use --prefer-offline on the first attempt to try to read from cache // in follow ups we just try to install in any way - `npm install ${attempt === 1 ? '--prefer-offline' : ''} --no-audit --no-fund`, + ['install', ...(attempt === 1 ? ['--prefer-offline'] : []), '--no-audit', '--no-fund'], { cwd, encoding: 'utf8', + timeout: NPM_INSTALL_TIMEOUT_MS, }, ); @@ -359,6 +370,9 @@ async function npmInstallWithRetry(cwd: string, deps: string[]): Promise { `npm install attempt ${attempt}/${NPM_INSTALL_MAX_RETRIES} failed, retrying in ${NPM_INSTALL_RETRY_DELAY_MS}ms...`, ); await new Promise(resolve => setTimeout(resolve, NPM_INSTALL_RETRY_DELAY_MS)); + // A killed attempt leaves a partial install behind, which the next attempt would treat as complete + await rm(join(cwd, 'node_modules'), { recursive: true, force: true }); + await rm(join(cwd, 'package-lock.json'), { force: true }); } else { // oxlint-disable-next-line no-console console.error(error); diff --git a/dev-packages/node-integration-tests/utils/runner/createRunner.ts b/dev-packages/node-integration-tests/utils/runner/createRunner.ts index d9fd1d5f149b..b592869e9aca 100644 --- a/dev-packages/node-integration-tests/utils/runner/createRunner.ts +++ b/dev-packages/node-integration-tests/utils/runner/createRunner.ts @@ -121,7 +121,7 @@ const COMPILE_CACHE_ENV: Record = // The Bun and Deno packages run these suites from their own folder. Scenarios read the working // directory (e.g. `modulesIntegration` reads its `package.json`), so it is always this package. -const PACKAGE_ROOT = join(__dirname, '..', '..'); +export const PACKAGE_ROOT = join(__dirname, '..', '..'); /** Node flags that preload a module before the entry point. */ const PRELOAD_FLAGS = ['--import', '--require', '-r']; @@ -180,6 +180,7 @@ export function createRunner(...paths: string[]) { let withEnv: Record = {}; let withSentryServer = false; let ensureNoErrorOutput = false; + let autoFlush = true; // When set, the test using this runner expects `completed()` to reject (e.g. `test.fails` variants // created via `createEsmAndCjsTests` with `failsOnEsm`/`failsOnCjs`). We suppress the captured-log // dump in that case, since the failure is expected and the output would just be noise. @@ -330,6 +331,11 @@ export function createRunner(...paths: string[]) { unordered = true; return this; }, + /** Skip the injected `beforeExit` flush, so the test sees only what the SDK sends on its own. */ + withoutAutoFlush: function () { + autoFlush = false; + return this; + }, ensureNoErrorOutput: function () { if (expectedEnvelopes.length > 0) { throw new Error('You should not use `ensureNoErrorOutput` when using `expect`!'); @@ -630,7 +636,7 @@ export function createRunner(...paths: string[]) { // `should-exit`, `ensureNoErrorOutput`) verify the child exits // naturally and auto-flush would delay that with retrying HTTP // requests to the fake DSN. - const wantsAutoFlush = !ensureNoErrorOutput && hasExpectations; + const wantsAutoFlush = autoFlush && !ensureNoErrorOutput && hasExpectations; const runtime = getRuntime(); const childFlags = wantsAutoFlush ? [...buildAutoFlushFlags(flags, testPath, runtime), ...flags] : flags; const entryPath = buildScenario(runtime, testPath); diff --git a/dev-packages/optional-e2e-gh-action/action.yml b/dev-packages/optional-e2e-gh-action/action.yml index 684ee62d491f..54edd85f97df 100644 --- a/dev-packages/optional-e2e-gh-action/action.yml +++ b/dev-packages/optional-e2e-gh-action/action.yml @@ -1,15 +1,23 @@ name: 'optional-e2e-gh-action' -description: 'Run the optional E2E tests in their own workflow, wait for them and report failures in a PR comment' +description: + 'Run the optional E2E tests in their own workflow and wait for them, or report their results in a PR comment' inputs: github_token: required: true description: 'a github access token' + mode: + required: false + default: 'dispatch' + description: '`dispatch` to run the tests and wait for them, `report` to report the current run in a PR comment' commit: - required: true - description: 'The commit that the tarballs were built from' + required: false + description: 'The commit that the tarballs were built from (dispatch mode)' matrix: - required: true - description: 'The optional E2E test matrix as JSON' + required: false + description: 'The optional E2E test matrix as JSON (dispatch mode)' + pr_number: + required: false + description: 'The PR to report in (report mode)' runs: using: 'node24' main: 'index.mjs' diff --git a/dev-packages/optional-e2e-gh-action/index.mjs b/dev-packages/optional-e2e-gh-action/index.mjs index 54136e26542f..a67c689c16f4 100644 --- a/dev-packages/optional-e2e-gh-action/index.mjs +++ b/dev-packages/optional-e2e-gh-action/index.mjs @@ -12,6 +12,12 @@ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); async function run() { const octokit = getOctokit(core.getInput('github_token')); const { repo } = context; + + if (core.getInput('mode') === 'report') { + await report(octokit, repo); + return; + } + const pr = context.payload.pull_request; const correlationId = `${context.runId}-${process.env.GITHUB_RUN_ATTEMPT}`; @@ -41,15 +47,17 @@ async function run() { if (workflowRun.conclusion !== 'success') { core.warning(`Optional E2E tests ${workflowRun.conclusion}: ${workflowRun.html_url}`); } +} - if (pr) { - // A failed report must not fail this job, because that marks the PR as failed. - try { - await reportInComment(octokit, repo, pr.number, workflowRun); - } catch (error) { - core.warning(`Could not report the optional E2E results in a PR comment: ${error.message}`); - } +// Runs as the last job of the optional E2E run itself, so a re-run of that run also updates the comment. +async function report(octokit, repo) { + const prNumber = Number(core.getInput('pr_number')); + if (!prNumber) { + return; } + + const { data: workflowRun } = await octokit.rest.actions.getWorkflowRun({ ...repo, run_id: context.runId }); + await reportInComment(octokit, repo, prNumber, workflowRun); } async function waitForRun(octokit, repo, workflowId, correlationId) { @@ -80,8 +88,7 @@ async function waitForRun(octokit, repo, workflowId, correlationId) { return workflowRun; } -// Keeps one comment per PR up to date, so new pushes do not add more comments. The comment is only -// created when a check fails. +// Keeps one comment per PR up to date, so new pushes and re-runs do not add more comments. async function reportInComment(octokit, repo, prNumber, workflowRun) { const jobs = await octokit.paginate(octokit.rest.actions.listJobsForWorkflowRun, { ...repo, @@ -89,9 +96,13 @@ async function reportInComment(octokit, repo, prNumber, workflowRun) { filter: 'latest', per_page: 100, }); - const ran = jobs.filter(({ conclusion }) => conclusion !== 'skipped'); + // The report job itself is still in progress. + const ran = jobs.filter(({ status, conclusion }) => status === 'completed' && conclusion !== 'skipped'); const failing = ran.filter(({ conclusion }) => conclusion !== 'success'); const successful = ran.filter(({ conclusion }) => conclusion === 'success'); + if (!ran.length) { + return; + } const comments = await octokit.paginate(octokit.rest.issues.listComments, { ...repo, @@ -102,12 +113,8 @@ async function reportInComment(octokit, repo, prNumber, workflowRun) { ({ user, body }) => user?.login === 'github-actions[bot]' && body?.startsWith(COMMENT_MARKER), ); - if (!failing.length && !previousComment) { - return; - } - const counts = [ - failing.length && `${failing.length} failing`, + failing.length && `${failing.length} not successful`, successful.length && `${successful.length} successful`, ].filter(Boolean); const runLink = `[See the run](${workflowRun.html_url})`; @@ -151,16 +158,33 @@ function formatJobs(jobs) { } function formatStatus({ conclusion, started_at, completed_at }) { - const seconds = Math.round((new Date(completed_at) - new Date(started_at)) / 1000); - const duration = seconds >= 60 ? `${Math.floor(seconds / 60)}m` : `${seconds}s`; + const duration = formatDuration(started_at, completed_at); + const withDuration = (status, preposition) => (duration ? `${status} ${preposition} ${duration}` : status); + + switch (conclusion) { + case 'success': + return withDuration('Successful', 'in'); + case 'failure': + return withDuration('Failing', 'after'); + case 'timed_out': + return withDuration('Timed out', 'after'); + case 'cancelled': + return withDuration('Cancelled', 'after'); + default: + return 'Status unknown'; + } +} - if (conclusion === 'success') { - return `Successful in ${duration}`; +// A job that never started has no `started_at`, and `new Date(null)` is the epoch, which gives a wrong duration. +function formatDuration(startedAt, completedAt) { + if (!startedAt || !completedAt) { + return undefined; } - if (conclusion === 'cancelled') { - return `Cancelled after ${duration}`; + const seconds = Math.round((new Date(completedAt) - new Date(startedAt)) / 1000); + if (!Number.isFinite(seconds) || seconds < 0) { + return undefined; } - return `Failing after ${duration}`; + return seconds >= 60 ? `${Math.floor(seconds / 60)}m` : `${seconds}s`; } run().catch(error => core.setFailed(error.message)); diff --git a/dev-packages/optional-e2e-gh-action/package.json b/dev-packages/optional-e2e-gh-action/package.json index ffc3e38564cf..a35e3621cd4e 100644 --- a/dev-packages/optional-e2e-gh-action/package.json +++ b/dev-packages/optional-e2e-gh-action/package.json @@ -17,8 +17,5 @@ "@actions/core": "1.10.1", "@actions/github": "^5.0.0", "markdown-table": "3.0.3" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/rollup-utils/npmHelpers.mjs b/dev-packages/rollup-utils/npmHelpers.mjs index a113feb1aa75..a6ded756d615 100644 --- a/dev-packages/rollup-utils/npmHelpers.mjs +++ b/dev-packages/rollup-utils/npmHelpers.mjs @@ -5,7 +5,7 @@ */ import * as fs from 'fs'; -import { builtinModules } from 'module'; +import { builtinModules, isBuiltin } from 'module'; import * as path from 'path'; import { fileURLToPath } from 'url'; @@ -44,18 +44,49 @@ function isSideEffectFreePackage(packageName) { let sideEffectFree = false; try { - const json = JSON.parse( - fs.readFileSync(path.resolve(repoRoot, 'node_modules', packageName, 'package.json'), { encoding: 'utf8' }), - ); + const json = JSON.parse(fs.readFileSync(findPackageJson(packageName), { encoding: 'utf8' })); sideEffectFree = json.sideEffects === false; } catch { - // Not resolvable from the repo root - assume side effects. + // Not resolvable from the built package - assume side effects. + if (isInstalledPackage(packageName)) { + // oxlint-disable-next-line no-console + console.warn( + `[rollup-utils] Cannot read the package.json of "${packageName}" from ${process.cwd()}, so its imports are kept. Declare it in the package.json of the built package.`, + ); + } } sideEffectsCache.set(packageName, sideEffectFree); return sideEffectFree; } +/** + * The `package.json` of a dependency of the built package (the working directory): the nearest + * `node_modules/` from there up to the repo root, like Node resolves it. + */ +function findPackageJson(packageName) { + for (let dir = process.cwd(); ; dir = path.dirname(dir)) { + const candidate = path.join(dir, 'node_modules', packageName, 'package.json'); + if (fs.existsSync(candidate) || dir === repoRoot || dir === path.dirname(dir)) { + return candidate; + } + } +} + +/** + * Whether `packageName` must have a `package.json` in a `node_modules` folder of the built package. This is false for + * builtins, for virtual modules and placeholders (e.g. `$app`, `cloudflare:workers`), which are no valid npm package + * names, for the built package itself, and for optional peers, which are not always installed. + */ +function isInstalledPackage(packageName) { + return ( + !isBuiltin(packageName) && + /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(packageName) && + packageName !== packageDotJSON.name && + !packageDotJSON.peerDependenciesMeta?.[packageName]?.optional + ); +} + function getPackageName(id) { const segments = id.split('/'); return id.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0]; @@ -108,6 +139,8 @@ export function makeBaseNPMConfig(options = {}) { // Allow wrappers or helper functions generated by rollup to use any ES2015 features generatedCode: { preset: 'es2015', + // With arrow functions, Node can't see `export *` exports when ESM code imports our CJS files. + arrowFunctions: false, }, // don't add `"use strict"` to the top of cjs files diff --git a/dev-packages/rollup-utils/package.json b/dev-packages/rollup-utils/package.json index 0c8602f49eff..30a34165adb3 100644 --- a/dev-packages/rollup-utils/package.json +++ b/dev-packages/rollup-utils/package.json @@ -15,8 +15,5 @@ "devDependencies": { "eslint-plugin-regexp": "^3.1.0" }, - "volta": { - "extends": "../../package.json" - }, "type": "module" } diff --git a/dev-packages/size-limit-gh-action/index.mjs b/dev-packages/size-limit-gh-action/index.mjs index 9baaf3b96630..b23a752b1e94 100644 --- a/dev-packages/size-limit-gh-action/index.mjs +++ b/dev-packages/size-limit-gh-action/index.mjs @@ -33,16 +33,14 @@ async function run() { } const octokit = getOctokit(githubToken); + let increaseAccepted = false; if (comparisonBranch) { const { data: currentPr } = await octokit.rest.pulls.get({ ...repo, pull_number: pr.number, }); - if (currentPr.labels.some(label => label.name === OVERRIDE_LABEL)) { - core.info(`Bundle size increase acknowledged by "${OVERRIDE_LABEL}". Skipping size measurement.`); - return; - } + increaseAccepted = currentPr.labels.some(label => label.name === OVERRIDE_LABEL); } const limit = new SizeLimitFormatter(); @@ -155,7 +153,7 @@ async function run() { } else if (!base) { failure = 'No baseline size measurements found. Re-run after the base build completes.'; bodyParts.push(failure); - } else if (increases.length > 0) { + } else if (increases.length > 0 && !increaseAccepted) { failure = `One or more gzipped bundles increased by more than ${MAX_INCREASE_BYTES} bytes. ` + `If this increase is intentional, add the **${OVERRIDE_LABEL}** label to this PR to rerun and accept the check.`; diff --git a/dev-packages/size-limit-gh-action/package.json b/dev-packages/size-limit-gh-action/package.json index 6c7848539fa2..ccb8a7a88194 100644 --- a/dev-packages/size-limit-gh-action/package.json +++ b/dev-packages/size-limit-gh-action/package.json @@ -22,8 +22,5 @@ "@actions/io": "3.0.2", "bytes-iec": "3.1.1", "markdown-table": "3.0.3" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/test-utils/package.json b/dev-packages/test-utils/package.json index 4feb491f03f8..5b346f948464 100644 --- a/dev-packages/test-utils/package.json +++ b/dev-packages/test-utils/package.json @@ -66,11 +66,10 @@ }, "devDependencies": { "@playwright/test": "1.64.1", + "@sentry-internal/rollup-utils": "11.9.0", "@sentry/core": "11.9.0", + "@types/express": "^4.17.21", "@types/ws": "^8.18.1", "eslint-plugin-regexp": "^3.1.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/dev-packages/test-utils/src/build-output.ts b/dev-packages/test-utils/src/build-output.ts index f42e5dd1bdb0..f174069321ce 100644 --- a/dev-packages/test-utils/src/build-output.ts +++ b/dev-packages/test-utils/src/build-output.ts @@ -37,6 +37,11 @@ function* walkFiles(outputDir: string, extensions?: string[]): Generator if (extensions && !extensions.includes(path.extname(entry.name))) { continue; } + // Dependencies a server build traces into its output are not the app's bundles. Some ship debug + // IDs of their own, for example the `sentry` package. + if (path.relative(outputDir, entry.parentPath).split(path.sep).includes('node_modules')) { + continue; + } yield path.join(entry.parentPath, entry.name); } } @@ -160,6 +165,28 @@ export function findInjectedDebugIds({ outputDir, extensions = JS_EXTENSIONS }: return [...debugIds]; } +/** + * Returns the debug IDs of emitted files whose path has glob characters, such as Nitro 3's + * `_libs/_sentry/node+[...].mjs`. + * + * TODO: The uploader passes the emitted file paths to `glob` as patterns, so it skips these files. + * Remove this when the uploader matches paths literally. + */ +export function findDebugIdsInFilesWithGlobCharacters({ + outputDir, + extensions = JS_EXTENSIONS, +}: OutputScanOptions): string[] { + const debugIds = new Set(); + + for (const file of walkFiles(outputDir, extensions)) { + if (/[[\]{}()*?]/.test(path.relative(outputDir, file))) { + readDebugIds(file).forEach(debugId => debugIds.add(debugId)); + } + } + + return [...debugIds]; +} + /** * Returns ` → ` for each file with more than one debug ID. * The uploader reads the first ID, but the SDK can report any of them. diff --git a/dev-packages/test-utils/src/bundler-instrumentation.ts b/dev-packages/test-utils/src/bundler-instrumentation.ts index 6cafc6fd8e5f..f61676c6eb04 100644 --- a/dev-packages/test-utils/src/bundler-instrumentation.ts +++ b/dev-packages/test-utils/src/bundler-instrumentation.ts @@ -69,10 +69,12 @@ interface BundleRun { * * @param fixtureOrName A built-in fixture name (e.g. `'graphql'`) or an {@link InstrumentationFixture}. * @param options.appDir The test app directory holding `dist//main.*`. Defaults to `cwd`. + * @param options.importHook The `--import` entry that registers the runtime hook for the external + * variants. Defaults to `@sentry/node/import`; SDKs that wrap `@sentry/node` ship their own. */ export function assertBundlerInstrumentation( fixtureOrName: string | InstrumentationFixture, - { appDir = process.cwd() }: { appDir?: string } = {}, + { appDir = process.cwd(), importHook = '@sentry/node/import' }: { appDir?: string; importHook?: string } = {}, ): void { const fixture = typeof fixtureOrName === 'string' ? FIXTURES[fixtureOrName] : fixtureOrName; if (!fixture) { @@ -98,7 +100,7 @@ export function assertBundlerInstrumentation( // result to a file (not stdout) so a piped, buffered write can't be truncated by the child's exit. const runBundle = (name: string, withImport: boolean): BundleRun => { const resultFile = join(tmpdir(), `sentry-bundler-e2e-${name}-${randomUUID()}.json`); - const args = withImport ? ['--import', '@sentry/node/import', entryPath(name)] : [entryPath(name)]; + const args = withImport ? ['--import', importHook, entryPath(name)] : [entryPath(name)]; const label = `${name}${withImport ? ' (--import)' : ''}`; let stdout: string; try { diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index bb70d9382f9e..3b82c4a81515 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -17,12 +17,14 @@ export { getSpanOp, } from './event-proxy-server'; export type { SerializedStreamedSpan } from '@sentry/core'; +export { isServerSpanAt, makeClientReportEnvelope, watchForTunnelSpan } from './tunnel'; export { findAbsolutePathImports, findSourceMapFiles, findSourceMappingUrlComments, findInjectedDebugIds, + findDebugIdsInFilesWithGlobCharacters, findFilesWithMultipleDebugIds, findFilesWithForeignSourcemaps, bundleReferencesModule, @@ -44,6 +46,8 @@ export type { NodeServerOptions, NodeServer } from './node-server'; export { startMockSentryServer } from './mock-sentry-server'; export type { MockSentryServerOptions, MockSentryServer } from './mock-sentry-server'; export * from './sourcemap-upload-utils'; +export { assertSourceMapUpload } from './sourcemap-upload-assertions'; +export type { SourceMapUploadAssertionOptions, SourceMapUploadAssertionResult } from './sourcemap-upload-assertions'; export { CDPClient } from './cdp-client'; export type { CDPClientOptions } from './cdp-client'; diff --git a/dev-packages/test-utils/src/sourcemap-upload-assertions.ts b/dev-packages/test-utils/src/sourcemap-upload-assertions.ts new file mode 100644 index 000000000000..8dc16efeb4ee --- /dev/null +++ b/dev-packages/test-utils/src/sourcemap-upload-assertions.ts @@ -0,0 +1,186 @@ +/* oxlint-disable no-console -- the log is the context for a failed assertion */ +import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import { findInjectedDebugIds, findSourceMapFiles, findSourceMappingUrlComments } from './build-output'; +import type { ArtifactBundleData, RequestRecord, SourcemapEntry } from './sourcemap-upload-utils'; +import { + getArtifactBundles, + getAssembleRequests, + getChunkUploadPosts, + getDebugIdPairs, + getSourcemaps, + loadMockServerResults, +} from './sourcemap-upload-utils'; + +export interface SourceMapUploadAssertionOptions { + /** Directories with emitted JavaScript. Every debug ID injected there must have an uploaded source map. */ + outputDirs: string[]; + /** Directories that must hold no `.map` file and no `sourceMappingURL` comment. Defaults to `outputDirs`. */ + strippedDirs?: string[]; + /** + * Strings that an uploaded source map must carry in its `sourcesContent`, keyed by what they prove, for example + * `{ 'client source': 'SOURCEMAP_MARKER_CLIENT' }`. Put the marker in a comment, so the bundler strips it from + * the code but keeps it in the map. + */ + markers: Record; + /** Defaults to `fake-auth-token`. */ + authToken?: string; + /** Defaults to `test-project`. */ + project?: string; + /** Defaults to `test-release`. */ + release?: string; + /** + * Debug IDs of chunks without a source map, for which no upload is expected. Either the IDs themselves or a + * file with one ID per line. Defaults to `.tmp_debug_ids_without_source_map`, which a build plugin writes + * while the maps still exist, before the upload deletes them. A missing file means no exemptions. + */ + debugIdsWithoutSourceMap?: Iterable | string; + /** Build logs that must not carry the uploader's "no matching sources" warning. Missing files are skipped. */ + buildLogFiles?: string[]; + /** Defaults to the requests the mock Sentry server recorded. */ + requests?: RequestRecord[]; +} + +export interface SourceMapUploadAssertionResult { + requests: RequestRecord[]; + bundles: ArtifactBundleData[]; + sourcemaps: SourcemapEntry[]; + /** Lowercased debug IDs that have an uploaded JS and source map pair. */ + uploadedDebugIds: Set; +} + +function readDebugIdList(source: Iterable | string | undefined): Set { + if (source !== undefined && typeof source !== 'string') { + return new Set([...source].map(debugId => debugId.toLowerCase())); + } + + const file = source ?? '.tmp_debug_ids_without_source_map'; + if (!fs.existsSync(file)) { + return new Set(); + } + + return new Set( + fs + .readFileSync(file, 'utf8') + .split('\n') + .filter(Boolean) + .map(debugId => debugId.toLowerCase()), + ); +} + +/** + * Asserts that a build uploaded its source maps to the mock Sentry server and stripped them from its output. + * + * This is the contract every framework SDK keeps when it manages source maps for the user: the maps reach + * Sentry with the debug IDs the shipped bundles carry, the shipped bundles reference no map, and no map + * is left where it would be served. + * + * Returns what it read, so an app can add assertions of its own on top. + */ +export function assertSourceMapUpload(options: SourceMapUploadAssertionOptions): SourceMapUploadAssertionResult { + const { + outputDirs, + strippedDirs = outputDirs, + markers, + authToken = 'fake-auth-token', + project = 'test-project', + release = 'test-release', + buildLogFiles = ['.tmp_build_stdout', '.tmp_build_stderr'], + } = options; + + const requests = options.requests ?? loadMockServerResults(); + + console.log(`Captured ${requests.length} requests to mock Sentry server:\n`); + for (const request of requests) { + console.log(` ${request.method} ${request.url} (${request.bodySize} bytes)`); + } + console.log(''); + + // --- The upload reached Sentry --- + + assert.ok( + requests.some(r => r.authorization.includes(authToken)), + 'Expected requests with the configured auth token', + ); + + assert.ok( + getChunkUploadPosts(requests).some(r => r.bodySize > 0), + 'Expected at least one chunk upload POST with a non-empty body', + ); + + const assembleRequests = getAssembleRequests(requests); + assert.ok(assembleRequests.length > 0, 'Expected at least one assemble request'); + for (const request of assembleRequests) { + assert.ok(request.assembleBody?.projects?.includes(project), `Expected assemble request for ${project}`); + assert.equal(request.assembleBody?.version, release, 'Expected assemble request to reference the release'); + } + + const bundles = getArtifactBundles(requests); + assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest'); + + // --- The uploaded maps carry the app's source --- + + const sourcemaps = getSourcemaps(bundles); + assert.ok( + sourcemaps.some(map => map.sourcemap.mappings?.length), + 'Expected at least one uploaded source map with non-empty mappings', + ); + + for (const [proves, marker] of Object.entries(markers)) { + assert.ok( + sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))), + `Expected an uploaded source map carrying the ${proves} (marker ${marker})`, + ); + } + + const buildOutput = buildLogFiles + .filter(file => fs.existsSync(file)) + .map(file => fs.readFileSync(file, 'utf8')) + .join('\n'); + assert.ok( + !buildOutput.includes("Didn't find any matching sources for debug ID upload"), + 'Expected every build to find sources for the debug ID upload', + ); + + // --- Debug IDs tie the shipped bundles to the uploaded maps --- + + const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase())); + assert.ok(uploadedDebugIds.size > 0, 'Expected at least one JS/source map pair with matching debug IDs'); + + const exemptDebugIds = readDebugIdList(options.debugIdsWithoutSourceMap); + + for (const outputDir of outputDirs) { + const injectedDebugIds = findInjectedDebugIds({ outputDir }); + assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); + // The exemption for chunks without a source map must not let a build without any uploaded map pass. + assert.ok( + injectedDebugIds.some(debugId => uploadedDebugIds.has(debugId)), + `Expected at least one debug ID in ${outputDir} to have an uploaded source map`, + ); + + 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`); + + console.log(` ${outputDir}: ${injectedDebugIds.length} injected debug ID(s), all uploaded`); + } + + // --- Nothing is left where it would be served --- + + for (const strippedDir of strippedDirs) { + // A surviving map hands out the original source to anyone who requests it. + assert.deepEqual(findSourceMapFiles({ outputDir: strippedDir }), [], `Expected no source maps in ${strippedDir}`); + // The maps are gone, so a surviving reference only 404s in devtools and leaks where they were. + assert.deepEqual( + findSourceMappingUrlComments({ outputDir: strippedDir }), + [], + `Expected no sourceMappingURL comments in ${strippedDir}`, + ); + + console.log(` ${strippedDir}: free of source maps and sourceMappingURL comments`); + } + console.log(''); + + return { requests, bundles, sourcemaps, uploadedDebugIds }; +} diff --git a/dev-packages/test-utils/src/tunnel.ts b/dev-packages/test-utils/src/tunnel.ts new file mode 100644 index 000000000000..e0497174e357 --- /dev/null +++ b/dev-packages/test-utils/src/tunnel.ts @@ -0,0 +1,44 @@ +import type { SerializedStreamedSpan } from '@sentry/core'; +import { getSpanOp, waitForStreamedSpan } from './event-proxy-server'; + +type SpanMatcher = (span: SerializedStreamedSpan) => boolean; + +/** + * Matches an `http.server` span whose `url.path` equals or matches `path`. + */ +export function isServerSpanAt(path: string | RegExp): SpanMatcher { + return span => { + const urlPath = String(span.attributes['url.path']?.value ?? ''); + + return getSpanOp(span) === 'http.server' && (typeof path === 'string' ? urlPath === path : path.test(urlPath)); + }; +} + +/** + * Starts listening for a span of a tunnel request. Call it before the tunnel traffic, then call the returned function + * with a regular request made after it. Spans flush in order, so it resolves to `'tunnel'` if the tunnel request + * created a span, and to `'anchor'` otherwise, without waiting on a timeout. + */ +export function watchForTunnelSpan( + proxyServerName: string, + isTunnelSpan: SpanMatcher, +): (isAnchorSpan: SpanMatcher, requestAnchor: () => Promise) => Promise<'tunnel' | 'anchor'> { + const tunnelSpanPromise = waitForStreamedSpan(proxyServerName, isTunnelSpan); + + return async (isAnchorSpan, requestAnchor) => { + const anchorSpanPromise = waitForStreamedSpan(proxyServerName, isAnchorSpan); + await requestAnchor(); + + return Promise.race([ + tunnelSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + }; +} + +/** + * An envelope with an empty client report, which Sentry accepts without creating anything. + */ +export function makeClientReportEnvelope(dsn: string): string { + return `${JSON.stringify({ dsn, sent_at: new Date().toISOString() })}\n${JSON.stringify({ type: 'client_report' })}\n${JSON.stringify({ timestamp: Date.now() / 1000, discarded_events: [] })}\n`; +} diff --git a/docs/new-sdk-release-checklist.md b/docs/new-sdk-release-checklist.md index f113924cda0a..b6c58e3f62d5 100644 --- a/docs/new-sdk-release-checklist.md +++ b/docs/new-sdk-release-checklist.md @@ -12,8 +12,7 @@ differ slightly for other SDKs depending on how they are structured and how they - [ ] The package has a working unit testing environment - [ ] The package builds correctly (inspect `/build` directory) - [ ] The package is in the `packages` list of `pnpm-workspace.yaml` and in the `workspaces` list of the root - `package.json` (in the same order), and it is in the root `devDependencies` with the version `workspace:*`. - `scripts/verify-packages-versions.js` checks this. + `package.json` (in the same order). `scripts/verify-packages-versions.js` checks this. - [ ] Make sure that the `README.md` content is up to date and contains at least: - [ ] The correct name + a brief description of the SDK diff --git a/package.json b/package.json index ef21f823efff..954928582f62 100644 --- a/package.json +++ b/package.json @@ -49,9 +49,16 @@ "yalc:publish": "nx run-many -t yalc:publish" }, "packageManager": "pnpm@12.11.2", - "volta": { - "node": "24.22.1", - "pnpm": "9.15.9" + "//_devEngines_comment": [ + "pnpm's global `node` shim runs the nearest `devEngines.runtime` version, see CONTRIBUTING.md. Packages and E2E apps override it with their own entry.", + "`onFail: warn` keeps Node.js out of the lockfile, so `pnpm run` uses the Node.js of `setup-node` in the CI Node.js version matrix." + ], + "devEngines": { + "runtime": { + "name": "node", + "version": "24.22.1", + "onFail": "warn" + } }, "//_workspaces_comment": [ "pnpm reads the workspace packages from pnpm-workspace.yaml. Deno and the repo scripts read this list.", @@ -123,9 +130,9 @@ "dev-packages/bundler-tests" ], "//_devDependencies_comment": [ - "Every workspace package is listed with `workspace:*`, so that pnpm links it into the root node_modules.", - "Yarn workspaces did this for all packages, and some configs import workspace packages that they do not declare,", - "for example `@sentry-internal/rollup-utils` in the rollup configs." + "`@sentry-internal/node-integration-tests`: its suites import it by name, also from tmp folders with their own package.json.", + "`@sentry/eslint-plugin-sdk`: the root oxlint config loads it as a JS plugin.", + "`@sentry/sveltekit`: size-limit bundles its build output, which imports `@sentry/sveltekit/browser-tracing-variant`." ], "devDependencies": { "@rollup/plugin-commonjs": "^25.0.7", @@ -133,69 +140,9 @@ "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-replace": "^5.0.5", "@rollup/plugin-terser": "^0.4.4", - "@sentry-internal/browser-integration-tests": "workspace:*", - "@sentry-internal/bun-integration-tests": "workspace:*", - "@sentry-internal/bundler-plugin-integration-tests": "workspace:*", - "@sentry-internal/bundler-tests": "workspace:*", - "@sentry-internal/clear-cache-gh-action": "workspace:*", - "@sentry-internal/cloudflare-integration-tests": "workspace:*", - "@sentry-internal/deno-integration-tests": "workspace:*", - "@sentry-internal/e2e-tests": "workspace:*", - "@sentry-internal/external-contributor-gh-action": "workspace:*", - "@sentry-internal/integration-shims": "workspace:*", "@sentry-internal/node-integration-tests": "workspace:*", - "@sentry-internal/optional-e2e-gh-action": "workspace:*", - "@sentry-internal/replay-worker": "workspace:*", - "@sentry-internal/rollup-utils": "workspace:*", - "@sentry-internal/size-limit-gh-action": "workspace:*", - "@sentry-internal/test-utils": "workspace:*", - "@sentry/angular": "workspace:*", - "@sentry/astro": "workspace:*", - "@sentry/aws-serverless": "workspace:*", - "@sentry/browser": "workspace:*", - "@sentry/browser-utils": "workspace:*", - "@sentry/bun": "workspace:*", - "@sentry/bundler-plugins": "workspace:*", - "@sentry/cloudflare": "workspace:*", - "@sentry/core": "workspace:*", - "@sentry/deno": "workspace:*", - "@sentry/effect": "workspace:*", - "@sentry/elysia": "workspace:*", - "@sentry/ember": "workspace:*", - "@sentry/esbuild-plugin": "workspace:*", - "@sentry/eslint-config-sdk": "workspace:*", "@sentry/eslint-plugin-sdk": "workspace:*", - "@sentry/feedback": "workspace:*", - "@sentry/gatsby": "workspace:*", - "@sentry/google-cloud-serverless": "workspace:*", - "@sentry/hono": "workspace:*", - "@sentry/nestjs": "workspace:*", - "@sentry/nextjs": "workspace:*", - "@sentry/nitro": "workspace:*", - "@sentry/node": "workspace:*", - "@sentry/node-native": "workspace:*", - "@sentry/nuxt": "workspace:*", - "@sentry/opentelemetry": "workspace:*", - "@sentry/profiling-node": "workspace:*", - "@sentry/react": "workspace:*", - "@sentry/react-router": "workspace:*", - "@sentry/remix": "workspace:*", - "@sentry/replay": "workspace:*", - "@sentry/replay-canvas": "workspace:*", - "@sentry/rollup-plugin": "workspace:*", - "@sentry/server-runtime-injection": "workspace:*", - "@sentry/server-utils": "workspace:*", - "@sentry/solid": "workspace:*", - "@sentry/solidstart": "workspace:*", - "@sentry/svelte": "workspace:*", "@sentry/sveltekit": "workspace:*", - "@sentry/tanstackstart-react": "workspace:*", - "@sentry/typescript": "workspace:*", - "@sentry/vercel-edge": "workspace:*", - "@sentry/vite-plugin": "workspace:*", - "@sentry/vue": "workspace:*", - "@sentry/wasm": "workspace:*", - "@sentry/webpack-plugin": "workspace:*", "@size-limit/esbuild": "~12.1.0", "@size-limit/file": "~12.1.0", "@size-limit/webpack": "~12.1.0", @@ -203,7 +150,7 @@ "@types/node": "^20.19.0", "@vitest/coverage-v8": "^4.1.11", "deepmerge": "^4.2.2", - "es-check": "^7.2.1", + "es-check": "^9.8.1", "esbuild": "^0.28.1", "jsdom": "^21.1.2", "madge": "8.0.0", @@ -218,9 +165,11 @@ "rollup-plugin-esbuild": "^6.2.1", "rollup-plugin-license": "^3.7.1", "size-limit": "~12.1.0", + "tslib": "^2.4.1", "tsx": "^4.23.0", "typescript": "~7.0.2", "vitest": "^4.1.11", + "webpack": "^5.104.1", "yalc": "^1.0.0-pre.53" }, "version": "0.0.0", diff --git a/packages/angular/README.md b/packages/angular/README.md index 77ac7ab11ebf..3190d09bb0b9 100644 --- a/packages/angular/README.md +++ b/packages/angular/README.md @@ -1,6 +1,10 @@

- Sentry + + + + Sentry +

diff --git a/packages/angular/package.json b/packages/angular/package.json index 562a1caf08e7..eeb70cb12cf1 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -23,7 +23,7 @@ "dependencies": { "@sentry/browser": "11.9.0", "@sentry/core": "11.9.0", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "tslib": "^2.4.1" }, "devDependencies": { @@ -61,9 +61,6 @@ "test:unit:watch": "vitest --watch", "yalc:publish": "yalc publish --push --sig" }, - "volta": { - "extends": "../../package.json" - }, "sideEffects": false, "nx": { "targets": { diff --git a/packages/astro/README.md b/packages/astro/README.md index 203680b44c58..0828fa925701 100644 --- a/packages/astro/README.md +++ b/packages/astro/README.md @@ -1,6 +1,10 @@

- Sentry + + + + Sentry +

diff --git a/packages/astro/package.json b/packages/astro/package.json index 7303a7fde594..bf1ef9a81cad 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -53,13 +53,14 @@ "dependencies": { "@sentry/browser": "11.9.0", "@sentry/core": "11.9.0", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/node": "11.9.0", "@sentry/server-runtime-injection": "11.9.0", "@sentry/server-utils": "11.9.0", "@sentry/bundler-plugins": "11.9.0" }, "devDependencies": { + "@sentry-internal/rollup-utils": "11.9.0", "astro": "^4.16.19", "vite": "^8.3.1" }, @@ -83,9 +84,6 @@ "test:watch": "vitest --watch", "yalc:publish": "yalc publish --push --sig" }, - "volta": { - "extends": "../../package.json" - }, "astro": { "external": true }, diff --git a/packages/astro/src/client/sdk.ts b/packages/astro/src/client/sdk.ts index 14effd00cacb..bd05cbd24579 100644 --- a/packages/astro/src/client/sdk.ts +++ b/packages/astro/src/client/sdk.ts @@ -2,6 +2,7 @@ import type { BrowserOptions } from '@sentry/browser'; import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as initBrowserSdk } from '@sentry/browser'; import type { Client, Integration } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; +import { applyAstroTunnelOptions } from '../tunnel'; import { browserTracingIntegration } from './browserTracingIntegration'; import { createAstroRouteProvider } from './routeProvider'; @@ -23,6 +24,7 @@ export function init(options: BrowserOptions): Client | undefined { }; applySdkMetadata(opts, 'astro', ['astro', 'browser']); + applyAstroTunnelOptions(opts); return initBrowserSdk(opts); } diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 80158e311e49..69a5899ae619 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -13,6 +13,7 @@ export { addIntegration, amqplibIntegration, anthropicAIIntegration, + awsIntegration, // eslint-disable-next-line typescript/no-deprecated anrIntegration, googleGenAIIntegration, @@ -102,6 +103,7 @@ export { langChainIntegration, langGraphIntegration, createFlueInstrumentation, + instrumentPostgresJsSql, mastraIntegration, piDurableIntegration, mcpServerIntegration, @@ -161,6 +163,11 @@ export { systemErrorIntegration, tediousIntegration, trpcMiddleware, + serveTunnelIntegration, + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, updateSpanName, vercelAIIntegration, withActiveSpan, @@ -177,6 +184,8 @@ export { instrumentLangChainEmbeddings, instrumentStateGraph, instrumentStateGraphCompile, + instrumentCreateReactAgent, + moduleMetadataIntegration, zodErrorsIntegration, profiler, logger, diff --git a/packages/astro/src/integration/cloudflare.ts b/packages/astro/src/integration/cloudflare.ts index 69e7ff7be99e..429504c6781a 100644 --- a/packages/astro/src/integration/cloudflare.ts +++ b/packages/astro/src/integration/cloudflare.ts @@ -39,7 +39,7 @@ export function sentryCloudflareNodeWarningPlugin(): VitePlugin { // The Worker entry module: `@astrojs/cloudflare` v12 builds its own virtual entry, and v13+ builds // through `@cloudflare/vite-plugin`, whose entry ends in `export default mod.default ?? {};`. -const WORKER_ENTRY_IDS = ['astrojs-ssr-virtual-entry', 'virtual:cloudflare/worker-entry']; +export const WORKER_ENTRY_IDS = ['astrojs-ssr-virtual-entry', 'virtual:cloudflare/worker-entry']; /** * A Vite plugin that ensures the Sentry server config is loaded at the diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts index 396413b64939..66b5de50861b 100644 --- a/packages/astro/src/integration/index.ts +++ b/packages/astro/src/integration/index.ts @@ -7,7 +7,9 @@ import { createRequire } from 'module'; import * as path from 'path'; import type { VitePlugin } from './cloudflare'; import { sentryCloudflareNodeWarningPlugin, sentryCloudflareVitePlugin } from './cloudflare'; +import { makeServerInitVitePlugin, SENTRY_MIDDLEWARE_MODULE, SERVER_INIT_IMPORT } from './serverInit'; import { buildClientSnippet, buildSdkInitFileImportSnippet, buildServerSnippet } from './snippets'; +import { getAstroTunnelOptions, makeTunnelOptionsVitePlugin, TUNNEL_OPTIONS_IMPORT } from './tunnel'; import type { SentryOptions } from './types'; const PKG_NAME = '@sentry/astro'; @@ -128,15 +130,21 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { }); } + const tunnelOptions = getAstroTunnelOptions(config); + if (tunnelOptions) { + updateConfig({ vite: { plugins: [makeTunnelOptionsVitePlugin(tunnelOptions)] } }); + } + const tunnelOptionsImport = tunnelOptions ? TUNNEL_OPTIONS_IMPORT : ''; + if (sdkEnabled.client) { const pathToClientInit = clientInitPath ? path.resolve(clientInitPath) : findDefaultSdkInitFile('client'); if (pathToClientInit) { debug && logger.info(`Using ${pathToClientInit} for client init.`); - injectScript('page', buildSdkInitFileImportSnippet(pathToClientInit)); + injectScript('page', tunnelOptionsImport + buildSdkInitFileImportSnippet(pathToClientInit)); } else { debug && logger.info('Using default client init.'); - injectScript('page', buildClientSnippet(options || {})); + injectScript('page', tunnelOptionsImport + buildClientSnippet(options || {})); } } @@ -174,18 +182,24 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { } } + let serverInitSnippet = ''; if (sdkEnabled.server) { const pathToServerInit = serverInitPath ? path.resolve(serverInitPath) : findDefaultSdkInitFile('server'); if (pathToServerInit) { debug && logger.info(`Using ${pathToServerInit} for server init.`); - // Always inject the server config via `injectScript('page-ssr')`. - // This ensures Sentry.init() runs in dev mode (where the Vite plugin doesn't fire) - // and also serves as the fallback for non-Cloudflare adapters in production. - injectScript('page-ssr', buildSdkInitFileImportSnippet(pathToServerInit)); + serverInitSnippet = tunnelOptionsImport + buildSdkInitFileImportSnippet(pathToServerInit); } else if (!usesCloudflareVitePlugin) { debug && logger.info('Using default server init.'); - injectScript('page-ssr', buildServerSnippet(options || {})); + serverInitSnippet = tunnelOptionsImport + buildServerSnippet(options || {}); + } + + // Always inject the server config via `injectScript('page-ssr')`. + // This ensures Sentry.init() runs in dev mode (where the Vite plugin doesn't fire) + // and also serves as the fallback for non-Cloudflare adapters in production. + if (serverInitSnippet) { + updateConfig({ vite: { plugins: [makeServerInitVitePlugin(serverInitSnippet)] } }); + injectScript('page-ssr', SERVER_INIT_IMPORT); } if (isCloudflareWorkers && command !== 'dev') { @@ -234,7 +248,7 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { if (isSSR && shouldAddMiddleware) { addMiddleware({ order: 'pre', - entrypoint: '@sentry/astro/middleware', + entrypoint: serverInitSnippet ? SENTRY_MIDDLEWARE_MODULE : '@sentry/astro/middleware', }); } }, diff --git a/packages/astro/src/integration/middleware/index.ts b/packages/astro/src/integration/middleware/index.ts index 12c56283016a..07a7bab9e55f 100644 --- a/packages/astro/src/integration/middleware/index.ts +++ b/packages/astro/src/integration/middleware/index.ts @@ -1,5 +1,9 @@ import { handleRequest } from '../../server/middleware'; +// The one `@sentry/astro` entry that resolves to server code on every runtime, so the integration's virtual module +// imports it from here. +export { _INTERNAL_setManagedTunnelDefaults } from '@sentry/core/server'; + type MiddlewareNext = () => Promise; type MiddlewareHandler = (ctx: unknown, next: MiddlewareNext) => Promise | Response | Promise | void; diff --git a/packages/astro/src/integration/serverInit.ts b/packages/astro/src/integration/serverInit.ts new file mode 100644 index 000000000000..111ad3c06b42 --- /dev/null +++ b/packages/astro/src/integration/serverInit.ts @@ -0,0 +1,34 @@ +import type { VitePlugin } from './cloudflare'; + +const SERVER_INIT_MODULE = 'virtual:@sentry/astro/server-init'; + +/** + * The middleware entrypoint the integration registers instead of `@sentry/astro/middleware` when it injects the init. + */ +export const SENTRY_MIDDLEWARE_MODULE = 'virtual:@sentry/astro/middleware'; + +/** + * The `page-ssr` script that runs the server init. + */ +export const SERVER_INIT_IMPORT = `import "${SERVER_INIT_MODULE}";\n`; + +/** + * Serves the server init as one module that SSR pages and the Sentry middleware both import, so it runs once, on + * whichever request comes first. With only the `page-ssr` script, a request that renders no SSR page, like a tunnel + * request from a prerendered page on a cold server, would find no client. + */ +export function makeServerInitVitePlugin(serverInitSnippet: string): VitePlugin { + return { + name: 'sentry-astro-server-init', + resolveId: id => (id === SERVER_INIT_MODULE || id === SENTRY_MIDDLEWARE_MODULE ? `\0${id}` : null), + load: id => { + if (id === `\0${SERVER_INIT_MODULE}`) { + return serverInitSnippet; + } + + return id === `\0${SENTRY_MIDDLEWARE_MODULE}` + ? `${SERVER_INIT_IMPORT}export { onRequest } from "@sentry/astro/middleware";\n` + : null; + }, + }; +} diff --git a/packages/astro/src/integration/tunnel.ts b/packages/astro/src/integration/tunnel.ts new file mode 100644 index 000000000000..edf6c3c0b8c4 --- /dev/null +++ b/packages/astro/src/integration/tunnel.ts @@ -0,0 +1,45 @@ +import type { BrowserTunnelOptions } from '@sentry/core'; +import type { AstroConfig } from 'astro'; +import { TUNNEL_OPTIONS_GLOBAL } from '../tunnel'; +import { WORKER_ENTRY_IDS, type VitePlugin } from './cloudflare'; + +const TUNNEL_OPTIONS_MODULE = 'virtual:@sentry/astro/tunnel-options'; +const RESOLVED_TUNNEL_OPTIONS_MODULE = `\0${TUNNEL_OPTIONS_MODULE}`; + +/** The tunnel options Astro's routing requires, or `undefined` if the defaults work. */ +export function getAstroTunnelOptions( + config: Pick, +): BrowserTunnelOptions | undefined { + const basePath = config.base?.replace(/\/$/, ''); + const trailingSlash = config.trailingSlash === 'always'; + + return basePath || trailingSlash + ? { ...(basePath && { basePath }), ...(trailingSlash && { trailingSlash }) } + : undefined; +} + +/** Imported first in the injected init scripts, so the options are set before `Sentry.init` runs. */ +export const TUNNEL_OPTIONS_IMPORT = `import "${TUNNEL_OPTIONS_MODULE}";\n`; + +/** + * Serves the module that hands the tunnel options to the SDKs. On the server it goes through core's managed tunnel + * defaults, because the server client isn't always `@sentry/astro`'s: on Cloudflare Workers it's `@sentry/cloudflare`'s, + * created per request. The Worker entry imports the module first, so the defaults are set before any client exists. + */ +export function makeTunnelOptionsVitePlugin(tunnelOptions: BrowserTunnelOptions): VitePlugin { + return { + name: 'sentry-astro-tunnel-options', + resolveId: id => (id === TUNNEL_OPTIONS_MODULE ? RESOLVED_TUNNEL_OPTIONS_MODULE : null), + load: (id, options) => { + if (id !== RESOLVED_TUNNEL_OPTIONS_MODULE) { + return null; + } + + return options?.ssr + ? `import { _INTERNAL_setManagedTunnelDefaults } from '@sentry/astro/middleware';\n_INTERNAL_setManagedTunnelDefaults(${JSON.stringify({ basePath: tunnelOptions.basePath })});` + : `globalThis[${JSON.stringify(TUNNEL_OPTIONS_GLOBAL)}] = ${JSON.stringify(tunnelOptions)};`; + }, + transform: (code, id) => + WORKER_ENTRY_IDS.some(entryId => id.includes(entryId)) ? TUNNEL_OPTIONS_IMPORT + code : null, + }; +} diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index 2e88b7fc160a..410fe0c9a8be 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -26,7 +26,10 @@ import { filterCollectedUrl, filterCollectedUrlQuery, } from '@sentry/core'; -import { flushIfServerless } from '@sentry/core/server'; +import { + flushIfServerless, + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, +} from '@sentry/core/server'; import { captureException, continueTrace, @@ -81,19 +84,17 @@ export const handleRequest: (options?: MiddlewareOptions) => MiddlewareHandler = const handlerOptions = { ...options }; return async (ctx, next) => { - // If no Sentry client exists, just bail - // Apart from the case when no Sentry.init() is called at all, this also happens - // if a prerendered page is hit first before a ssr page is called - // For regular prerendered pages, this is fine as we do not want to instrument them at runtime anyhow - // BUT for server-islands requests on a static page, this can be problematic... - // TODO: Today, this leads to inconsistent behavior: If a prerendered page is hit first (before _any_ ssr page is called), - // Sentry.init() has not been called yet (as this is only injected in SSR pages), so server-island requests are not instrumented - // If any SSR route is hit before, the client will already be set up and everything will work as expected :O - // To reproduce this: Run the astro-5 "tracing.serverIslands.test" only + // The middleware the integration adds runs the server init first, so this only bails without a server init, or when + // this middleware was added by hand and the request comes before any SSR page, which still runs the init. if (!getClient()) { return next(); } + const tunnelResponse = handleServedTunnelRequest(ctx.request); + if (tunnelResponse) { + return tunnelResponse; + } + const isDynamicPageRequest = checkIsDynamicPageRequest(ctx); // For static (prerendered) routes, we only want to inject the parametrized route meta tags diff --git a/packages/astro/src/tunnel.ts b/packages/astro/src/tunnel.ts new file mode 100644 index 000000000000..5c1b129911d2 --- /dev/null +++ b/packages/astro/src/tunnel.ts @@ -0,0 +1,14 @@ +import type { BrowserTunnelOptions, Options } from '@sentry/core'; +import { _INTERNAL_applyTunnelDefaults as applyTunnelDefaults, GLOBAL_OBJ } from '@sentry/core'; + +/** Set by the Sentry Astro integration from the `base` and `trailingSlash` config. */ +export const TUNNEL_OPTIONS_GLOBAL = '__sentry_astro_tunnel_options'; + +/** + * Serves the managed tunnel under the app's base path and with Astro's trailing slash policy, which Astro enforces + * before any middleware runs. Options the user set win. + */ +export function applyAstroTunnelOptions(options: Pick): void { + const astroTunnelOptions = (GLOBAL_OBJ as { [TUNNEL_OPTIONS_GLOBAL]?: BrowserTunnelOptions })[TUNNEL_OPTIONS_GLOBAL]; + options.tunnel = applyTunnelDefaults(options.tunnel, astroTunnelOptions); +} diff --git a/packages/astro/test/buildOptions.test-d.ts b/packages/astro/test/buildOptions.test-d.ts index 275e47ae68ee..da853204f179 100644 --- a/packages/astro/test/buildOptions.test-d.ts +++ b/packages/astro/test/buildOptions.test-d.ts @@ -6,7 +6,7 @@ describe('Sentry Astro build-time options type', () => { const completeOptions: SentryOptions = { // --- BuildTimeOptionsBase options --- org: 'test-org', - project: 'test-project', + project: ['test-project', 'other-project'], authToken: 'test-auth-token', sentryUrl: 'https://sentry.io', headers: { Authorization: ' Bearer test-auth-token' }, diff --git a/packages/astro/test/integration/index.files.test.ts b/packages/astro/test/integration/index.files.test.ts index 7df9f16ac0ca..125333ede7be 100644 --- a/packages/astro/test/integration/index.files.test.ts +++ b/packages/astro/test/integration/index.files.test.ts @@ -30,9 +30,11 @@ describe('sentryAstro integration', () => { expect(injectScript).toHaveBeenCalledTimes(2); expect(injectScript).toHaveBeenCalledWith('page', expect.stringMatching(/^import ".*\/sentry.client.config.js"/)); - expect(injectScript).toHaveBeenCalledWith( - 'page-ssr', - expect.stringMatching(/^import ".*\/sentry.server.config.js"/), + const serverInitPlugin = updateConfig.mock.calls + .flatMap(([viteConfig]) => viteConfig.vite?.plugins ?? []) + .find(plugin => plugin?.name === 'sentry-astro-server-init'); + expect(serverInitPlugin.load(serverInitPlugin.resolveId('virtual:@sentry/astro/server-init'))).toMatch( + /^import ".*\/sentry.server.config.js"/, ); }); }); diff --git a/packages/astro/test/integration/index.test.ts b/packages/astro/test/integration/index.test.ts index 42e9af789247..62481501bd23 100644 --- a/packages/astro/test/integration/index.test.ts +++ b/packages/astro/test/integration/index.test.ts @@ -59,6 +59,15 @@ const config = { outDir: new URL('file://path/to/project/out'), } as AstroConfig; +// The server init the integration serves to SSR pages and the middleware as one module. +function getServerInit(updateConfigMock: typeof updateConfig): string | undefined { + const plugin = updateConfigMock.mock.calls + .flatMap(([viteConfig]) => viteConfig.vite?.plugins ?? []) + .find(plugin => plugin?.name === 'sentry-astro-server-init'); + + return plugin?.load(plugin.resolveId('virtual:@sentry/astro/server-init')); +} + const baseConfigHookObject = { logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, addMiddleware: vi.fn(), @@ -85,8 +94,8 @@ describe('sentryAstro integration', () => { // @ts-expect-error - the hook exists and we only need to pass what we actually use await integration.hooks['astro:config:setup']({ ...baseConfigHookObject, updateConfig, injectScript, config }); - // one call for the sourcemaps vite plugin, one for the orchestrion plugin - expect(updateConfig).toHaveBeenCalledTimes(2); + // one call each for the sourcemaps vite plugin, the orchestrion plugin and the server init + expect(updateConfig).toHaveBeenCalledTimes(3); expect(updateConfig).toHaveBeenCalledWith({ vite: { build: { @@ -338,8 +347,8 @@ describe('sentryAstro integration', () => { // @ts-expect-error - the hook exists and we only need to pass what we actually use await integration.hooks['astro:config:setup']({ ...baseConfigHookObject, updateConfig, injectScript, config }); - // only the orchestrion plugin is wired, no sourcemaps plugin - expect(updateConfig).toHaveBeenCalledTimes(1); + // only the orchestrion plugin and the server init are wired, no sourcemaps plugin + expect(updateConfig).toHaveBeenCalledTimes(2); expect(updateConfig).toHaveBeenCalledWith({ vite: { plugins: [{ name: 'sentry-orchestrion-vite' }], @@ -355,8 +364,8 @@ describe('sentryAstro integration', () => { // @ts-expect-error - the hook exists and we only need to pass what we actually use await integration.hooks['astro:config:setup']({ ...baseConfigHookObject, updateConfig, injectScript, config }); - // one call for the sourcemaps vite plugin, one for the orchestrion plugin - expect(updateConfig).toHaveBeenCalledTimes(2); + // one call each for the sourcemaps vite plugin, the orchestrion plugin and the server init + expect(updateConfig).toHaveBeenCalledTimes(3); expect(sentryVitePluginSpy).toHaveBeenCalledTimes(1); }); @@ -373,8 +382,8 @@ describe('sentryAstro integration', () => { command: 'dev', }); - // the sourcemaps plugin is skipped in dev, but the orchestrion plugin is still wired - expect(updateConfig).toHaveBeenCalledTimes(1); + // the sourcemaps plugin is skipped in dev, but the orchestrion plugin and the server init are still wired + expect(updateConfig).toHaveBeenCalledTimes(2); expect(updateConfig).toHaveBeenCalledWith({ vite: { plugins: [{ name: 'sentry-orchestrion-vite' }], @@ -522,7 +531,7 @@ describe('sentryAstro integration', () => { config: cloudflareConfig, }); - expect(injectScript).toHaveBeenCalledWith('page-ssr', expect.stringContaining('my-server-init-path.js')); + expect(getServerInit(updateConfig)).toContain('my-server-init-path.js'); }); }); @@ -576,7 +585,7 @@ describe('sentryAstro integration', () => { expect(injectScript).toHaveBeenCalledTimes(2); expect(injectScript).toHaveBeenCalledWith('page', expect.stringContaining('Sentry.init')); - expect(injectScript).toHaveBeenCalledWith('page-ssr', expect.stringContaining('Sentry.init')); + expect(getServerInit(updateConfig)).toContain('Sentry.init'); }); it('passes build-time release options to the Sentry vite plugin and init snippets', async () => { @@ -605,7 +614,7 @@ describe('sentryAstro integration', () => { 'page', expect.stringContaining('dsn: import.meta.env.PUBLIC_SENTRY_DSN'), ); - expect(injectScript).toHaveBeenCalledWith('page-ssr', expect.stringContaining('release: "1.0.0"')); + expect(getServerInit(updateConfig)).toContain('release: "1.0.0"'); }); it("doesn't inject client init script if `enabled.client` is `false`", async () => { @@ -616,7 +625,7 @@ describe('sentryAstro integration', () => { await integration.hooks['astro:config:setup']({ updateConfig, injectScript, config }); expect(injectScript).toHaveBeenCalledTimes(1); - expect(injectScript).toHaveBeenCalledWith('page-ssr', expect.stringContaining('Sentry.init')); + expect(getServerInit(updateConfig)).toContain('Sentry.init'); }); it("doesn't inject server init script if `enabled.server` is `false`", async () => { @@ -655,10 +664,7 @@ describe('sentryAstro integration', () => { expect(injectScript).toHaveBeenCalledTimes(2); expect(injectScript).toHaveBeenCalledWith('page', expect.stringMatching(/^import ".*\/my-client-init-path.js"/)); - expect(injectScript).toHaveBeenCalledWith( - 'page-ssr', - expect.stringMatching(/^import ".*\/my-server-init-path.js"/), - ); + expect(getServerInit(updateConfig)).toMatch(/^import ".*\/my-server-init-path.js"/); }); it.each(['server', 'hybrid'])('adds middleware by default if in %s mode', async mode => { @@ -680,8 +686,34 @@ describe('sentryAstro integration', () => { expect(addMiddleware).toHaveBeenCalledTimes(1); expect(addMiddleware).toHaveBeenCalledWith({ order: 'pre', - entrypoint: '@sentry/astro/middleware', + entrypoint: 'virtual:@sentry/astro/middleware', + }); + }); + + it('runs the server init once, from SSR pages and the middleware alike, so requests that render no page find a client', async () => { + const integration = sentryAstro({}); + const updateConfig = vi.fn(); + const injectScript = vi.fn(); + + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + // @ts-expect-error - we only need to pass what we actually use + config: { output: 'server' }, + addMiddleware: vi.fn(), + updateConfig, + injectScript, }); + + const plugin = updateConfig.mock.calls + .flatMap(([viteConfig]) => viteConfig.vite?.plugins ?? []) + .find(plugin => plugin?.name === 'sentry-astro-server-init'); + const serverInitImport = 'import "virtual:@sentry/astro/server-init";\n'; + + expect(injectScript).toHaveBeenCalledWith('page-ssr', serverInitImport); + expect(plugin.load(plugin.resolveId('virtual:@sentry/astro/middleware'))).toBe( + `${serverInitImport}export { onRequest } from "@sentry/astro/middleware";\n`, + ); + expect(getServerInit(updateConfig)).toContain('Sentry.init'); }); it.each([{ output: 'static' }, { output: undefined }])( diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index b205af292372..fd9f0dc2a139 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -2,6 +2,7 @@ import { HTTP_REQUEST_METHOD, SENTRY_SEGMENT_NAME_SOURCE, URL_FULL, URL_PATH } f import type { Client, Span } from '@sentry/core'; import * as SentryCore from '@sentry/core'; +import * as SentryCoreServer from '@sentry/core/server'; import * as SentryNode from '@sentry/node'; import type { APIContext } from 'astro'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -33,6 +34,27 @@ const STATIC_REQUEST_CONTEXT = { url: new URL('https://myDomain.io/users/'), }; +describe('sentryMiddleware with the managed tunnel', () => { + it('answers tunnel requests without calling the rest of the middleware chain', async () => { + const getClientSpy = vi.spyOn(SentryNode, 'getClient').mockReturnValue({} as Client); + const tunnelResponse = new Response('forwarded'); + const matcherSpy = vi + .spyOn(SentryCoreServer, '_INTERNAL_handleServedTunnelRequest') + .mockReturnValue(Promise.resolve(tunnelResponse)); + const next = vi.fn(); + const request = new Request('https://myDomain.io/abc123', { method: 'POST' }); + + const response = await handleRequest()({ ...DYNAMIC_REQUEST_CONTEXT, request } as unknown as APIContext, next); + + expect(response).toBe(tunnelResponse); + expect(matcherSpy).toHaveBeenCalledWith(request); + expect(next).not.toHaveBeenCalled(); + + getClientSpy.mockRestore(); + matcherSpy.mockRestore(); + }); +}); + describe('sentryMiddleware', () => { const startSpanSpy = vi.spyOn(SentryNode, 'startSpan'); diff --git a/packages/astro/test/tunnel.test.ts b/packages/astro/test/tunnel.test.ts new file mode 100644 index 000000000000..1e8a65f4f694 --- /dev/null +++ b/packages/astro/test/tunnel.test.ts @@ -0,0 +1,48 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { getAstroTunnelOptions, makeTunnelOptionsVitePlugin } from '../src/integration/tunnel'; +import { applyAstroTunnelOptions, TUNNEL_OPTIONS_GLOBAL } from '../src/tunnel'; + +const globalWithOptions = GLOBAL_OBJ as { __sentry_astro_tunnel_options?: unknown }; + +describe('getAstroTunnelOptions', () => { + it('maps `base` and `trailingSlash: always` onto tunnel options', () => { + expect(getAstroTunnelOptions({ base: '/docs/', trailingSlash: 'always' })).toEqual({ + basePath: '/docs', + trailingSlash: true, + }); + expect(getAstroTunnelOptions({ base: '/docs', trailingSlash: 'ignore' })).toEqual({ basePath: '/docs' }); + }); + + it('returns nothing for the defaults', () => { + expect(getAstroTunnelOptions({ base: '/', trailingSlash: 'ignore' })).toBeUndefined(); + }); +}); + +describe('makeTunnelOptionsVitePlugin', () => { + it('serves a module that sets the tunnel options global', () => { + const plugin = makeTunnelOptionsVitePlugin({ basePath: '/docs' }); + const resolved = (plugin.resolveId as unknown as (id: string) => string | null)( + 'virtual:@sentry/astro/tunnel-options', + ); + + expect((plugin.load as unknown as (id: string) => string | null)(resolved!)).toBe( + `globalThis[${JSON.stringify(TUNNEL_OPTIONS_GLOBAL)}] = {"basePath":"/docs"};`, + ); + }); +}); + +describe('applyAstroTunnelOptions', () => { + afterEach(() => { + delete globalWithOptions.__sentry_astro_tunnel_options; + }); + + it('adds the Astro routing options to the managed tunnel', () => { + globalWithOptions.__sentry_astro_tunnel_options = { basePath: '/docs', trailingSlash: true }; + const options: Parameters[0] = { tunnel: true }; + + applyAstroTunnelOptions(options); + + expect(options.tunnel).toEqual({ basePath: '/docs', trailingSlash: true }); + }); +}); diff --git a/packages/aws-serverless/README.md b/packages/aws-serverless/README.md index 5f77ea674cbd..fc183f7dd7e2 100644 --- a/packages/aws-serverless/README.md +++ b/packages/aws-serverless/README.md @@ -1,6 +1,10 @@

- Sentry + + + + Sentry +

diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 2c20d4ae4bab..ee96999c0236 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -50,13 +50,23 @@ "require": { "default": "./build/npm/cjs/awslambda-auto.js" } + }, + "./esbuild": { + "import": { + "types": "./build/npm/types/bundler-plugin/esbuild.d.ts", + "default": "./build/npm/esm/bundler-plugin/esbuild.js" + }, + "require": { + "types": "./build/npm/types/bundler-plugin/esbuild.d.ts", + "default": "./build/npm/cjs/bundler-plugin/esbuild.js" + } } }, "publishConfig": { "access": "public" }, "dependencies": { - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/core": "11.9.0", "@sentry/node": "11.9.0", "@sentry/server-runtime-injection": "11.9.0", @@ -64,6 +74,7 @@ "@types/aws-lambda": "^8.10.161" }, "devDependencies": { + "@sentry-internal/rollup-utils": "11.9.0", "@types/node": "^20.19.0", "@vercel/nft": "^1.11.0" }, @@ -89,9 +100,6 @@ "test:watch": "vitest --watch", "yalc:publish": "yalc publish --push --sig" }, - "volta": { - "extends": "../../package.json" - }, "sideEffects": false, "nx": { "targets": { diff --git a/packages/aws-serverless/rollup.npm.config.mjs b/packages/aws-serverless/rollup.npm.config.mjs index ff398be22729..9c89af481558 100644 --- a/packages/aws-serverless/rollup.npm.config.mjs +++ b/packages/aws-serverless/rollup.npm.config.mjs @@ -31,7 +31,7 @@ export default [ // TODO: `awslambda-auto.ts` is a file which the lambda layer uses to automatically init the SDK. Does it need to be // in the npm package? Is it possible that some people are using it themselves in the same way the layer uses it (in // which case removing it would be a breaking change)? Should it stay here or not? - entrypoints: ['src/index.ts', 'src/awslambda-auto.ts'], + entrypoints: ['src/index.ts', 'src/awslambda-auto.ts', 'src/bundler-plugin/esbuild.ts'], // packages with bundles have a different build directory structure hasBundles: true, packageSpecificConfig: { diff --git a/packages/aws-serverless/src/bundler-plugin/esbuild.ts b/packages/aws-serverless/src/bundler-plugin/esbuild.ts new file mode 100644 index 000000000000..0177c7dfd2b2 --- /dev/null +++ b/packages/aws-serverless/src/bundler-plugin/esbuild.ts @@ -0,0 +1,2 @@ +export { sentryEsbuildPlugin } from '@sentry/node/esbuild'; +export type { SentryEsbuildPluginOptions } from '@sentry/node/esbuild'; diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index fd07e76a3fba..095858178815 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -2,6 +2,7 @@ export { addEventProcessor, addBreadcrumb, addIntegration, + moduleMetadataIntegration, captureException, captureEvent, captureMessage, @@ -71,6 +72,7 @@ export { mcpServerIntegration, SentryMastraExporter, createFlueInstrumentation, + instrumentPostgresJsSql, modulesIntegration, nodeRuntimeMetricsIntegration, type NodeRuntimeMetricsOptions, @@ -149,6 +151,11 @@ export { spanToBaggageHeader, systemErrorIntegration, trpcMiddleware, + serveTunnelIntegration, + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, updateSpanName, supabaseIntegration, instrumentSupabaseClient, @@ -159,6 +166,7 @@ export { instrumentGoogleGenAIClient, instrumentStateGraph, instrumentStateGraphCompile, + instrumentCreateReactAgent, zodErrorsIntegration, profiler, amqplibIntegration, @@ -189,6 +197,7 @@ export { eveIntegration, getInstrumentedModuleNames, } from '@sentry/node'; +export type { HandleTunnelRequestOptions, ServerTunnelOptions, TunnelRequestLike } from '@sentry/node'; export { captureConsoleIntegration, diff --git a/packages/aws-serverless/src/requestSpanOptions.ts b/packages/aws-serverless/src/requestSpanOptions.ts index 82c10b13852a..aa4e6a354f2f 100644 --- a/packages/aws-serverless/src/requestSpanOptions.ts +++ b/packages/aws-serverless/src/requestSpanOptions.ts @@ -63,7 +63,7 @@ export function getRequestSpanOptions(event: unknown, context: Context, requestI : context.functionName, attributes: { [SENTRY_OP]: FUNCTION_AWS, - [SENTRY_ORIGIN]: 'auto.aws_lambda', + [SENTRY_ORIGIN]: 'auto.function.aws_serverless', [SENTRY_SEGMENT_NAME_SOURCE]: 'component', [SENTRY_KIND]: 'server', [FAAS_INVOCATION_ID]: context.awsRequestId, diff --git a/packages/aws-serverless/test/bundler-plugin.test.ts b/packages/aws-serverless/test/bundler-plugin.test.ts new file mode 100644 index 000000000000..9a190740a7ef --- /dev/null +++ b/packages/aws-serverless/test/bundler-plugin.test.ts @@ -0,0 +1,9 @@ +import { sentryEsbuildPlugin as nodeEsbuildPlugin } from '@sentry/node/esbuild'; +import { describe, expect, it } from 'vitest'; +import { sentryEsbuildPlugin } from '../src/bundler-plugin/esbuild'; + +describe('@sentry/aws-serverless bundler plugin', () => { + it('re-exports the @sentry/node esbuild plugin', () => { + expect(sentryEsbuildPlugin).toBe(nodeEsbuildPlugin); + }); +}); diff --git a/packages/browser-utils/README.md b/packages/browser-utils/README.md index 0e8f9a34a390..4b7fce1a36f5 100644 --- a/packages/browser-utils/README.md +++ b/packages/browser-utils/README.md @@ -1,6 +1,10 @@

- Sentry + + + + Sentry +

diff --git a/packages/browser-utils/package.json b/packages/browser-utils/package.json index d65a49a54c40..bad56e53adf6 100644 --- a/packages/browser-utils/package.json +++ b/packages/browser-utils/package.json @@ -34,9 +34,12 @@ }, "dependencies": { "@sentry/core": "11.9.0", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "web-vitals": "^6.2.3" }, + "devDependencies": { + "@sentry-internal/rollup-utils": "11.9.0" + }, "scripts": { "build": "run-p build:transpile build:types", "build:dev": "pnpm run build", @@ -56,8 +59,5 @@ "test:watch": "vitest --watch", "yalc:publish": "yalc publish --push --sig" }, - "volta": { - "extends": "../../package.json" - }, "sideEffects": false } diff --git a/packages/browser-utils/src/htmlTreeAsString.ts b/packages/browser-utils/src/htmlTreeAsString.ts index 1fd7f75590bf..46dcc493a431 100644 --- a/packages/browser-utils/src/htmlTreeAsString.ts +++ b/packages/browser-utils/src/htmlTreeAsString.ts @@ -2,6 +2,8 @@ import { isString } from '@sentry/core'; const DEFAULT_MAX_STRING_LENGTH = 80; +type MaskAttributeFn = (key: string, value: string, el: HTMLElement) => string; + type SimpleNode = { parentNode: SimpleNode; } | null; @@ -54,7 +56,7 @@ function _safeRead(el: unknown, prop: AccessorKey, arg?: string): T { */ export function htmlTreeAsString( elem: unknown, - options: string[] | { keyAttrs?: string[]; maxStringLength?: number } = {}, + options: string[] | { keyAttrs?: string[]; maxStringLength?: number; maskAttributeFn?: MaskAttributeFn } = {}, ): string { if (!elem) { return ''; @@ -74,10 +76,11 @@ export function htmlTreeAsString( const sepLength = separator.length; let nextStr; const keyAttrs = Array.isArray(options) ? options : options.keyAttrs; + const maskAttributeFn = Array.isArray(options) ? undefined : options.maskAttributeFn; const maxStringLength = (!Array.isArray(options) && options.maxStringLength) || DEFAULT_MAX_STRING_LENGTH; while (currentElem && height++ < MAX_TRAVERSE_HEIGHT) { - nextStr = _htmlElementAsString(currentElem, keyAttrs); + nextStr = _htmlElementAsString(currentElem, keyAttrs, maskAttributeFn); // bail out if // - nextStr is the 'html' element // - the length of the string that would be created exceeds maxStringLength @@ -103,7 +106,7 @@ export function htmlTreeAsString( * e.g. [HTMLElement] => input#foo.btn[name=baz] * @returns generated DOM path */ -function _htmlElementAsString(el: unknown, keyAttrs?: string[]): string { +function _htmlElementAsString(el: unknown, keyAttrs?: string[], maskAttributeFn?: MaskAttributeFn): string { const out = []; const tagName = _safeRead(el, 'tagName'); @@ -117,10 +120,14 @@ function _htmlElementAsString(el: unknown, keyAttrs?: string[]): string { const dataset = _safeRead(el, 'dataset'); if (dataset) { if (dataset['sentryComponent']) { - return dataset['sentryComponent']; + return maskAttributeFn + ? maskAttributeFn('data-sentry-component', dataset['sentryComponent'], el) + : dataset['sentryComponent']; } if (dataset['sentryElement']) { - return dataset['sentryElement']; + return maskAttributeFn + ? maskAttributeFn('data-sentry-element', dataset['sentryElement'], el) + : dataset['sentryElement']; } } } @@ -132,22 +139,24 @@ function _htmlElementAsString(el: unknown, keyAttrs?: string[]): string { const keyAttrPairs = keyAttrs?.length ? keyAttrs .filter(keyAttr => _safeRead(el, 'getAttribute', keyAttr)) - .map(keyAttr => [keyAttr, _safeRead(el, 'getAttribute', keyAttr)]) + .map(keyAttr => [keyAttr, _safeRead(el, 'getAttribute', keyAttr)] as const) : null; if (keyAttrPairs?.length) { - keyAttrPairs.forEach(keyAttrPair => { - out.push(`[${keyAttrPair[0]}="${keyAttrPair[1]}"]`); + keyAttrPairs.forEach(([key, value]) => { + out.push(`[${key}="${maskAttributeFn && value ? maskAttributeFn(key, value, el as HTMLElement) : value}"]`); }); } else { const id = _safeRead(el, 'id'); if (id) { - out.push(`#${id}`); + out.push(`#${maskAttributeFn ? maskAttributeFn('id', id, el as HTMLElement) : id}`); } const className = _safeRead(el, 'className'); if (className && isString(className)) { - const classes = className.split(/\s+/); + const classes = (maskAttributeFn ? maskAttributeFn('class', className, el as HTMLElement) : className).split( + /\s+/, + ); for (const c of classes) { out.push(`.${c}`); } @@ -156,7 +165,7 @@ function _htmlElementAsString(el: unknown, keyAttrs?: string[]): string { for (const k of ['aria-label', 'type', 'name', 'title', 'alt']) { const attr = _safeRead(el, 'getAttribute', k); if (attr) { - out.push(`[${k}="${attr}"]`); + out.push(`[${k}="${maskAttributeFn ? maskAttributeFn(k, attr, el as HTMLElement) : attr}"]`); } } diff --git a/packages/browser-utils/src/web-vitals/spans.ts b/packages/browser-utils/src/web-vitals/spans.ts index 1660a44cf273..3361254ffc1d 100644 --- a/packages/browser-utils/src/web-vitals/spans.ts +++ b/packages/browser-utils/src/web-vitals/spans.ts @@ -228,7 +228,7 @@ export function _sendLcpSpan( _emitWebVitalSpan({ name, op: UI_WEBVITAL_LCP, - origin: 'auto.http.browser.lcp', + origin: 'auto.ui.browser.lcp', metricName: 'lcp', value: lcpValue, attributes, @@ -329,7 +329,7 @@ export function _sendClsSpan( _emitWebVitalSpan({ name, op: UI_WEBVITAL_CLS, - origin: 'auto.http.browser.cls', + origin: 'auto.ui.browser.cls', metricName: 'cls', value: clsValue, attributes, @@ -457,7 +457,7 @@ export function _sendInpSpan( _emitWebVitalSpan({ name, op: INTERACTION_TYPE_TO_SPAN_OP[interactionType], - origin: 'auto.http.browser.inp', + origin: 'auto.ui.browser.inp', metricName: 'inp', value: inpValue, attributes, diff --git a/packages/browser-utils/test/htmlTreeAsString.test.ts b/packages/browser-utils/test/htmlTreeAsString.test.ts index 86db19c4143a..02b2cc9090dc 100644 --- a/packages/browser-utils/test/htmlTreeAsString.test.ts +++ b/packages/browser-utils/test/htmlTreeAsString.test.ts @@ -6,6 +6,46 @@ import { describe, expect, it } from 'vitest'; import { htmlTreeAsString } from '../src/htmlTreeAsString'; describe('htmlTreeAsString', () => { + it('masks attributes on the target and its ancestors', () => { + const parent = document.createElement('div'); + parent.title = 'Account PIN'; + const target = document.createElement('button'); + target.setAttribute('aria-label', 'Pay Alice'); + target.setAttribute('type', 'button'); + parent.appendChild(target); + + const result = htmlTreeAsString(target, { + maskAttributeFn: (key, value) => (['title', 'aria-label'].includes(key) ? '[masked]' : value), + }); + + expect(result).toBe('div[title="[masked]"] > button[aria-label="[masked]"][type="button"]'); + }); + + it('masks custom key attributes', () => { + const target = document.createElement('button'); + target.setAttribute('data-account', 'alice@example.com'); + + const result = htmlTreeAsString(target, { + keyAttrs: ['data-account'], + maskAttributeFn: (_key, _value, el) => (el === target ? '[masked]' : '[unexpected element]'), + }); + + expect(result).toBe('button[data-account="[masked]"]'); + }); + + it('returns unknown when attribute masking throws', () => { + const target = document.createElement('button'); + target.title = 'Account PIN'; + + const result = htmlTreeAsString(target, { + maskAttributeFn: () => { + throw new Error('Invalid masking selector'); + }, + }); + + expect(result).toBe(''); + }); + it('returns a query-selector-like string for a single element', () => { const el = document.createElement('button'); el.id = 'submit'; diff --git a/packages/browser-utils/test/web-vitals/spans.test.ts b/packages/browser-utils/test/web-vitals/spans.test.ts index 92c86637cbf2..ebbb00ab9331 100644 --- a/packages/browser-utils/test/web-vitals/spans.test.ts +++ b/packages/browser-utils/test/web-vitals/spans.test.ts @@ -99,7 +99,7 @@ describe('_emitWebVitalSpan', () => { _emitWebVitalSpan({ name: 'Test Vital', op: 'ui.webvital.lcp', - origin: 'auto.http.browser.lcp', + origin: 'auto.ui.browser.lcp', metricName: 'lcp', value: 100, startTime: 1.5, @@ -108,7 +108,7 @@ describe('_emitWebVitalSpan', () => { expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith({ name: 'Test Vital', attributes: { - 'sentry.origin': 'auto.http.browser.lcp', + 'sentry.origin': 'auto.ui.browser.lcp', 'sentry.op': 'ui.webvital.lcp', 'sentry.exclusive_time': 0, 'browser.web_vital.lcp.value': 100, @@ -135,7 +135,7 @@ describe('_emitWebVitalSpan', () => { _emitWebVitalSpan({ name: 'Test Vital', op: 'ui.webvital.lcp', - origin: 'auto.http.browser.lcp', + origin: 'auto.ui.browser.lcp', metricName: 'lcp', value: 100, startTime: 1.5, @@ -156,7 +156,7 @@ describe('_emitWebVitalSpan', () => { _emitWebVitalSpan({ name: 'Test', op: 'ui.interaction.click', - origin: 'auto.http.browser.inp', + origin: 'auto.ui.browser.inp', metricName: 'inp', value: 100, startTime: 1.5, @@ -176,7 +176,7 @@ describe('_emitWebVitalSpan', () => { _emitWebVitalSpan({ name: 'Test', op: 'ui.interaction.click', - origin: 'auto.http.browser.inp', + origin: 'auto.ui.browser.inp', metricName: 'inp', value: 100, startTime: 1.5, @@ -201,7 +201,7 @@ describe('_emitWebVitalSpan', () => { _emitWebVitalSpan({ name: 'Test', op: 'ui.interaction.click', - origin: 'auto.http.browser.inp', + origin: 'auto.ui.browser.inp', metricName: 'inp', value: 100, startTime: 1.5, @@ -223,7 +223,7 @@ describe('_emitWebVitalSpan', () => { _emitWebVitalSpan({ name: 'Test', op: 'ui.interaction.click', - origin: 'auto.http.browser.inp', + origin: 'auto.ui.browser.inp', metricName: 'inp', value: 100, startTime: 1.5, @@ -244,7 +244,7 @@ describe('_emitWebVitalSpan', () => { _emitWebVitalSpan({ name: 'Test', op: 'ui.webvital.lcp', - origin: 'auto.http.browser.lcp', + origin: 'auto.ui.browser.lcp', metricName: 'lcp', value: 50, parentSpan: mockPageloadSpan as any, @@ -270,7 +270,7 @@ describe('_emitWebVitalSpan', () => { _emitWebVitalSpan({ name: 'Test', op: 'ui.interaction.click', - origin: 'auto.http.browser.inp', + origin: 'auto.ui.browser.inp', metricName: 'inp', value: 50, parentSpan: mockNonPageloadSpan as any, @@ -290,7 +290,7 @@ describe('_emitWebVitalSpan', () => { _emitWebVitalSpan({ name: 'Test', op: 'ui.webvital.lcp', - origin: 'auto.http.browser.lcp', + origin: 'auto.ui.browser.lcp', metricName: 'lcp', value: 50, attributes: { 'custom.attr': 'value' }, @@ -313,7 +313,7 @@ describe('_emitWebVitalSpan', () => { _emitWebVitalSpan({ name: 'Test', op: 'ui.webvital.lcp', - origin: 'auto.http.browser.lcp', + origin: 'auto.ui.browser.lcp', metricName: 'lcp', value: 50, startTime: 1.0, @@ -333,7 +333,7 @@ describe('_emitWebVitalSpan', () => { _emitWebVitalSpan({ name: 'Test', op: 'ui.webvital.lcp', - origin: 'auto.http.browser.lcp', + origin: 'auto.ui.browser.lcp', metricName: 'lcp', value: 50, startTime: 1.0, @@ -351,7 +351,7 @@ describe('_emitWebVitalSpan', () => { _emitWebVitalSpan({ name: 'Test', op: 'ui.webvital.lcp', - origin: 'auto.http.browser.lcp', + origin: 'auto.ui.browser.lcp', metricName: 'lcp', value: 50, startTime: 1.0, @@ -419,7 +419,7 @@ describe('_sendLcpSpan', () => { expect.objectContaining({ name: '', attributes: expect.objectContaining({ - 'sentry.origin': 'auto.http.browser.lcp', + 'sentry.origin': 'auto.ui.browser.lcp', 'sentry.op': 'ui.webvital.lcp', 'sentry.exclusive_time': 0, 'sentry.pageload.span_id': 'pageload-123', @@ -567,7 +567,7 @@ describe('_sendClsSpan', () => { expect.objectContaining({ name: '
', attributes: expect.objectContaining({ - 'sentry.origin': 'auto.http.browser.cls', + 'sentry.origin': 'auto.ui.browser.cls', 'sentry.op': 'ui.webvital.cls', 'sentry.pageload.span_id': 'pageload-789', 'browser.web_vital.cls.source.1': '
', @@ -676,7 +676,7 @@ describe('_sendInpSpan', () => { name: '
} /> + Club
} /> + + , + ); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(getCurrentScope().getScopeData().transactionName).toEqual('/clubs/:clubId'); + }); + + it('parameterizes a navigation whose pathname has a percent-encoded parameter', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration(reactRouterBrowserTracingIntegration()); + + const SentryRoutes = wrapReactRouterRouting(Routes); + + function Home(): React.ReactElement { + const navigate = useNavigate(); + return ( + + ); + } + + const { getByText } = render( + + + } /> + Club} /> + + , + ); + + fireEvent.click(getByText('to club')); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/clubs/:clubId', + attributes: { + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + [URL_TEMPLATE]: '/clubs/:clubId', + [SENTRY_OP]: 'navigation', + [SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter', + }, + }); + }); + it('reactRouterBrowserTracingIntegration() instruments a navigation without passing router hooks', () => { const client = createMockBrowserClient(); setCurrentClient(client); diff --git a/packages/react/test/reactrouter-compat-utils/utils.test.ts b/packages/react/test/reactrouter-compat-utils/utils.test.ts index 7e1435521dc7..905562ba8cff 100644 --- a/packages/react/test/reactrouter-compat-utils/utils.test.ts +++ b/packages/react/test/reactrouter-compat-utils/utils.test.ts @@ -185,6 +185,28 @@ describe('reactrouter-compat-utils/utils', () => { }); describe('rebuildRoutePathFromAllRoutes', () => { + it('should match a percent-encoded pathname against the decoded pathnameBase', () => { + const allRoutes: RouteObject[] = [{ path: ':clubId', element: null }]; + + // What a browser's `location.pathname` holds for `/한글` + const location: Location = { pathname: '/%ED%95%9C%EA%B8%80' }; + + const mockMatches: RouteMatch[] = [ + { + route: { path: ':clubId', element: null }, + params: { clubId: '한글' }, + pathname: '/한글', + pathnameBase: '/한글', + }, + ]; + + // The recursive call matches the remaining routes (none) against the stripped pathname + mockMatchRoutes.mockImplementation((routes: RouteObject[]) => (routes.length ? mockMatches : [])); + + const result = rebuildRoutePathFromAllRoutes(allRoutes, location, mockMatchRoutes as MatchRoutes); + expect(result).toBe(':clubId'); + }); + it('should return pathname when it matches stripped path', () => { const allRoutes: RouteObject[] = [{ path: '/users', element: null }]; @@ -423,6 +445,112 @@ describe('reactrouter-compat-utils/utils', () => { }); describe('getNormalizedName', () => { + it('should parameterize a percent-encoded pathname whose branch pathname is decoded', () => { + const routes: RouteObject[] = [{ path: '/clubs/:clubId', element: null }]; + // What a browser's `location.pathname` holds for `/clubs/한글` + const location: Location = { pathname: '/clubs/%ED%95%9C%EA%B8%80' }; + const branches: RouteMatch[] = [ + { + route: { path: '/clubs/:clubId', element: null }, + params: { clubId: '한글' }, + pathname: '/clubs/한글', + pathnameBase: '/clubs/한글', + }, + ]; + + const result = getNormalizedName(routes, location, branches, ''); + expect(result).toEqual(['/clubs/:clubId', 'route']); + }); + + it('should keep matching when the basename itself is percent-encoded', () => { + const routes: RouteObject[] = [{ path: '/clubs/:clubId', element: null }]; + // React Router strips the basename from the raw pathname, so an encoded basename is passed encoded + const basename = '/%ED%95%9C%EA%B5%AD'; + const location: Location = { pathname: `${basename}/clubs/abc` }; + const branches: RouteMatch[] = [ + { + route: { path: '/clubs/:clubId', element: null }, + params: { clubId: 'abc' }, + pathname: '/clubs/abc', + pathnameBase: '/clubs/abc', + }, + ]; + + expect(getNormalizedName(routes, location, branches, basename)).toEqual([`${basename}/clubs/:clubId`, 'route']); + expect(getNormalizedName(routes, location, branches, basename, true)).toEqual(['/clubs/:clubId', 'route']); + }); + + it('should not double the slash when the basename ends with one', () => { + const routes: RouteObject[] = [{ path: '/clubs/:clubId', element: null }]; + const branches: RouteMatch[] = [ + { + route: { path: '/clubs/:clubId', element: null }, + params: { clubId: 'abc' }, + pathname: '/clubs/abc', + pathnameBase: '/clubs/abc', + }, + ]; + + // e.g. Vite's `import.meta.env.BASE_URL` always ends with a slash + expect(getNormalizedName(routes, { pathname: '/clubs/abc' }, branches, '/')).toEqual(['/clubs/:clubId', 'route']); + expect(getNormalizedName(routes, { pathname: '/app/clubs/abc' }, branches, '/app/')).toEqual([ + '/app/clubs/:clubId', + 'route', + ]); + expect(getNormalizedName(routes, { pathname: '/app/clubs/abc' }, branches, '/app/', true)).toEqual([ + '/clubs/:clubId', + 'route', + ]); + }); + + it('should keep matching when the router left the branch pathname encoded', () => { + // react-router < 6.5 matched against the raw pathname, 6.5 - 6.22 used `decodeURI` (keeps `%40`) + const routes: RouteObject[] = [{ path: '/users/:email', element: null }]; + const location: Location = { pathname: '/users/a%40b.com' }; + const branches: RouteMatch[] = [ + { + route: { path: '/users/:email', element: null }, + params: { email: 'a%40b.com' }, + pathname: '/users/a%40b.com', + pathnameBase: '/users/a%40b.com', + }, + ]; + + expect(getNormalizedName(routes, location, branches, '')).toEqual(['/users/:email', 'route']); + }); + + it('should parameterize a pathname whose parameter still looks percent-encoded after decoding', () => { + // `%2541` decodes to the literal `%41`; a second decode would turn it into `A` + const routes: RouteObject[] = [{ path: '/clubs/:clubId', element: null }]; + const location: Location = { pathname: '/clubs/%2541' }; + const branches: RouteMatch[] = [ + { + route: { path: '/clubs/:clubId', element: null }, + params: { clubId: '%41' }, + pathname: '/clubs/%41', + pathnameBase: '/clubs/%41', + }, + ]; + + expect(getNormalizedName(routes, location, branches, '')).toEqual(['/clubs/:clubId', 'route']); + }); + + it('should parameterize a pathname whose decoded parameter is not valid percent-encoding', () => { + // `100%25` decodes to `100%`, which cannot be decoded again + const routes: RouteObject[] = [{ path: '/clubs/:clubId', element: null }]; + const location: Location = { pathname: '/clubs/100%25' }; + const branches: RouteMatch[] = [ + { + route: { path: '/clubs/:clubId', element: null }, + params: { clubId: '100%' }, + pathname: '/clubs/100%', + pathnameBase: '/clubs/100%', + }, + ]; + + expect(getNormalizedName(routes, location, branches, '')).toEqual(['/clubs/:clubId', 'route']); + }); + it('should return pathname with url source when no routes provided', () => { const routes: RouteObject[] = []; const location: Location = { pathname: '/test' }; @@ -545,6 +673,21 @@ describe('reactrouter-compat-utils/utils', () => { }); describe('resolveRouteNameAndSource', () => { + it.each([ + { basename: '/app/', pathname: '/app/users/42', expected: '/app/users/:id' }, + { basename: '/', pathname: '/users/42', expected: '/users/:id' }, + ])('avoids double slashes in manifest route names with basename $basename', ({ basename, pathname, expected }) => { + const config = makeConfig({ + enableAsyncRouteHandlers: true, + lazyRouteManifest: ['/users/:id'], + basename, + }); + + const result = resolveRouteNameAndSource({ pathname }, [], [], [], config); + + expect(result).toEqual([expected, 'route']); + }); + it('should use descendant route when location is inside one', () => { const location: Location = { pathname: '/users/123/profile' }; const routes: RouteObject[] = [{ path: '/users', element: null }]; diff --git a/packages/react/test/reactrouter-descendant-routes.test.tsx b/packages/react/test/reactrouter-descendant-routes.test.tsx index 7f712ad0f5d7..64b810c6f7ab 100644 --- a/packages/react/test/reactrouter-descendant-routes.test.tsx +++ b/packages/react/test/reactrouter-descendant-routes.test.tsx @@ -84,6 +84,81 @@ describe('React Router Descendant Routes', () => { }); describe('withSentryReactRouterV6Routing', () => { + it('keeps the parent path prefix for a descendant route with a percent-encoded parameter', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + const ChildRouter = () => ( + + Child} /> + + ); + + const { container } = render( + // What a browser's `location.pathname` holds for `/child/한글` + + + } /> + + , + ); + + expect(container.innerHTML).toContain('Child'); + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(getCurrentScope().getScopeData().transactionName).toEqual('/child/:id'); + }); + + it('re-matches the descendant remainder without decoding it twice', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + const ChildRouter = () => ( + + Child} /> + + ); + + try { + const { container } = render( + // `100%25` decodes to `100%`, which React Router cannot decode a second time + + + } /> + + , + ); + + expect(container.innerHTML).toContain('Child'); + expect(getCurrentScope().getScopeData().transactionName).toEqual('/child/:id'); + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('could not be decoded')); + } finally { + warnSpy.mockRestore(); + } + }); + it('keeps the parent path prefix for descendant routes with non-wildcard nested children - pageload', () => { const client = createMockBrowserClient(); setCurrentClient(client); diff --git a/packages/remix/README.md b/packages/remix/README.md index 826159aa47c9..11bd3211116d 100644 --- a/packages/remix/README.md +++ b/packages/remix/README.md @@ -1,6 +1,10 @@

- Sentry + + + + Sentry +

diff --git a/packages/remix/package.json b/packages/remix/package.json index e76330a5810b..9ba20c92c624 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -78,7 +78,7 @@ "@remix-run/router": "^1.23.4", "@sentry/browser": "11.9.0", "@sentry/bundler-plugins": "11.9.0", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/core": "11.9.0", "@sentry/node": "11.9.0", "@sentry/react": "11.9.0", @@ -90,11 +90,15 @@ "devDependencies": { "@remix-run/node": "^2.17.5", "@remix-run/react": "^2.17.5", - "@remix-run/route-pattern": "^0.24.0", + "@remix-run/route-pattern": "^1.0.0", "@remix-run/server-runtime": "^2.17.4", + "@sentry-internal/rollup-utils": "11.9.0", "@types/express": "^4.17.14", + "@types/react": "17.0.3", + "@types/semver": "^7.5.3", "react": "^18.3.1", "react-dom": "^18.3.1", + "semver": "^7.8.5", "vite": "^8.3.1" }, "peerDependencies": { @@ -102,7 +106,7 @@ "@remix-run/react": "2.x", "@remix-run/server-runtime": "2.x", "react": "18.x", - "remix": ">=3.0.0-rc.1 <4" + "remix": "3.x" }, "scripts": { "build": "run-p build:transpile build:types", @@ -124,9 +128,6 @@ "test:watch": "vitest --watch --config vitest.config.unit.ts", "yalc:publish": "yalc publish --push --sig" }, - "volta": { - "extends": "../../package.json" - }, "sideEffects": false, "nx": { "targets": { diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 7b8b29d46a02..a1aaa76b57aa 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -121,6 +121,11 @@ export { startSpanManual, tediousIntegration, trpcMiddleware, + serveTunnelIntegration, + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, updateSpanName, withActiveSpan, withIsolationScope, diff --git a/packages/remix/src/server/sdk.ts b/packages/remix/src/server/sdk.ts index a51f4dd21aba..3c92bd0a05b3 100644 --- a/packages/remix/src/server/sdk.ts +++ b/packages/remix/src/server/sdk.ts @@ -34,7 +34,7 @@ export function init(options: RemixOptions): NodeClient | undefined { options.defaultIntegrations ??= getRemixDefaultIntegrations(options); - const client = nodeInit(options); + const client = nodeInit(options as NodeOptions); instrumentServer(); diff --git a/packages/remix/src/v3/server/instrument.ts b/packages/remix/src/v3/server/instrument.ts index f348c9ae01b0..f63578e2f0f9 100644 --- a/packages/remix/src/v3/server/instrument.ts +++ b/packages/remix/src/v3/server/instrument.ts @@ -1,6 +1,7 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import { createMultiMatcher } from 'remix/route-pattern/match'; import { consoleSandbox } from '@sentry/core'; +import { _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest } from '@sentry/core/server'; import type { OrchestrionChannelContext } from '@sentry/server-utils'; import { remixV3Channels } from '@sentry/server-utils/orchestrion/config'; @@ -58,9 +59,9 @@ function subscribeToCreateRouter(): void { } /** - * Report errors from any fetch handler, router or not. The middleware only sees failures inside a - * router; this is the only hook that covers a plain handler. No abort guard is needed, because the - * listener drops aborted requests before calling `onError`. + * Report errors from any fetch handler, router or not, and answer managed tunnel requests before it runs. + * The middleware only sees failures inside a router; this is the only hook that covers a plain handler. + * No abort guard is needed, because the listener drops aborted requests before calling `onError`. */ function subscribeToCreateRequestListener(): void { diagnosticsChannel @@ -71,6 +72,7 @@ function subscribeToCreateRequestListener(): void { start(data) { try { injectOnError(ensureOptions(data.arguments, 1)); + wrapFetchHandler(data.arguments); } catch { // Ignored on purpose. } @@ -82,6 +84,21 @@ function subscribeToCreateRequestListener(): void { }); } +/** + * Answers managed tunnel requests before the app's fetch handler, so code the app runs in front of its router never + * sees them. The router middleware still answers them for apps served some other way. + */ +function wrapFetchHandler(args: unknown[]): void { + const handler = args[0]; + if (typeof handler !== 'function') { + return; + } + + args[0] = function sentryFetchHandler(this: unknown, request: Request, ...rest: unknown[]): unknown { + return handleServedTunnelRequest(request) ?? handler.call(this, request, ...rest); + }; +} + function injectOnError(raw: Record | undefined): void { if (!raw || markInjected(raw)) { return; diff --git a/packages/remix/src/v3/server/middleware.ts b/packages/remix/src/v3/server/middleware.ts index f2ce59c48f01..4ca54f648383 100644 --- a/packages/remix/src/v3/server/middleware.ts +++ b/packages/remix/src/v3/server/middleware.ts @@ -10,6 +10,7 @@ import { updateSpanName, winterCGRequestToRequestData, } from '@sentry/core'; +import { _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest } from '@sentry/core/server'; import { formatRouteTiming } from '../routeTiming'; import type { MatcherLike, MiddlewareLike, NextFunctionLike, RequestContextLike } from '../types'; @@ -25,6 +26,11 @@ import { resolveRoutePattern } from './route'; */ export function sentryRemixMiddleware(matcher: MatcherLike): MiddlewareLike { return async function sentryMiddleware(context: RequestContextLike, next: NextFunctionLike): Promise { + const tunnelResponse = handleServedTunnelRequest(context.request); + if (tunnelResponse) { + return tunnelResponse; + } + const isolationScope = getIsolationScope(); isolationScope.setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(context.request) }); diff --git a/packages/remix/test/v3-exports.test.ts b/packages/remix/test/v3-exports.test.ts index a549611f10b8..a515a9f015b3 100644 --- a/packages/remix/test/v3-exports.test.ts +++ b/packages/remix/test/v3-exports.test.ts @@ -40,11 +40,9 @@ describe('Remix 3 exports', () => { describe('the Remix 3 peer range', () => { const range = packageJson.peerDependencies.remix; - // Asserted by behaviour, so the range can change after Remix 3 reaches GA without failing here. - it('accepts release candidates', () => { - // `3.x` does not match `3.0.0-rc.1`: semver ranges exclude prereleases unless they name one. - expect(semver.satisfies('3.0.0-rc.1', range)).toBe(true); - expect(semver.satisfies('3.0.0-rc.2', range)).toBe(true); + it('rejects release candidates', () => { + expect(semver.satisfies('3.0.0-rc.1', range)).toBe(false); + expect(semver.satisfies('3.0.0-rc.5', range)).toBe(false); }); it('accepts stable 3.x', () => { diff --git a/packages/remix/test/v3/instrument.test.ts b/packages/remix/test/v3/instrument.test.ts index 1a564d1f1758..85133e8b3ecd 100644 --- a/packages/remix/test/v3/instrument.test.ts +++ b/packages/remix/test/v3/instrument.test.ts @@ -13,6 +13,15 @@ vi.mock('@sentry/core', async importOriginal => ({ consoleSandbox: (callback: () => unknown) => callback(), })); +const TUNNEL_URL = 'http://localhost/fdv680'; +const tunnelResponse = new Response(null, { status: 200 }); + +// Answers only `TUNNEL_URL`, like a server with the managed tunnel at that path. +vi.mock('@sentry/core/server', () => ({ + _INTERNAL_handleServedTunnelRequest: (request: Request) => + request.url === TUNNEL_URL ? Promise.resolve(tunnelResponse) : undefined, +})); + const { instrumentRemixV3 } = await import('../../src/v3/server/instrument'); const startChannel = channel(`tracing:${remixV3Channels.REMIX_V3_CREATE_ROUTER}:start`); @@ -135,3 +144,33 @@ describe('the createRequestListener error hook', () => { expect(args[1]).toEqual(expect.objectContaining({ onError: expect.any(Function) })); }); }); + +describe('the createRequestListener tunnel', () => { + beforeAll(() => { + instrumentRemixV3(); + }); + + it("answers a managed tunnel request without calling the app's handler", async () => { + const appHandler = vi.fn(() => new Response('app')); + const args: unknown[] = [appHandler]; + + publishCreateRequestListener(args); + + expect(await (args[0] as (request: Request) => unknown)(new Request(TUNNEL_URL, { method: 'POST' }))).toBe( + tunnelResponse, + ); + expect(appHandler).not.toHaveBeenCalled(); + }); + + it("hands every other request to the app's handler", () => { + const appResponse = new Response('app'); + const appHandler = vi.fn(() => appResponse); + const args: unknown[] = [appHandler]; + const request = new Request('http://localhost/page'); + + publishCreateRequestListener(args); + + expect((args[0] as (request: Request, client: unknown) => unknown)(request, 'client')).toBe(appResponse); + expect(appHandler).toHaveBeenCalledWith(request, 'client'); + }); +}); diff --git a/packages/remix/test/v3/middleware.test.ts b/packages/remix/test/v3/middleware.test.ts index 3bc7031300bf..e0f1866e78fd 100644 --- a/packages/remix/test/v3/middleware.test.ts +++ b/packages/remix/test/v3/middleware.test.ts @@ -1,14 +1,24 @@ import type * as SentryCore from '@sentry/core'; +import type * as SentryCoreServer from '@sentry/core/server'; import { beforeEach, describe, expect, it, vi } from 'vitest'; // Trace data as the SDK would produce it for the active request; empty when nothing is active. const getTraceData = vi.hoisted(() => vi.fn<() => { 'sentry-trace'?: string; baggage?: string }>(() => ({}))); +const handleServedTunnelRequest = vi.hoisted(() => + vi.fn<(request: Request) => Promise | undefined>(() => undefined), +); + vi.mock('@sentry/core', async importOriginal => ({ ...(await importOriginal()), getTraceData, })); +vi.mock('@sentry/core/server', async importOriginal => ({ + ...(await importOriginal()), + _INTERNAL_handleServedTunnelRequest: handleServedTunnelRequest, +})); + import { sentryRemixMiddleware } from '../../src/v3/server/middleware'; import type { MatcherLike, RequestContextLike } from '../../src/v3/types'; @@ -27,6 +37,20 @@ function contextFor(url: string, headers: Record = {}): RequestC describe('sentryRemixMiddleware', () => { beforeEach(() => { getTraceData.mockReturnValue({}); + handleServedTunnelRequest.mockReturnValue(undefined); + }); + + it('answers tunnel requests before route matching and the rest of the chain', async () => { + const tunnelResponse = new Response('forwarded'); + handleServedTunnelRequest.mockReturnValue(Promise.resolve(tunnelResponse)); + const next = vi.fn(); + const context = contextFor('http://x/abc123'); + + const response = await sentryRemixMiddleware(matcherFor('/'))(context, next); + + expect(response).toBe(tunnelResponse); + expect(handleServedTunnelRequest).toHaveBeenCalledWith(context.request); + expect(next).not.toHaveBeenCalled(); }); it('propagates the trace to HTML responses through Server-Timing, ahead of the route', async () => { diff --git a/packages/replay-canvas/README.md b/packages/replay-canvas/README.md index 2faf422d6e30..7b6113fd292a 100644 --- a/packages/replay-canvas/README.md +++ b/packages/replay-canvas/README.md @@ -1,6 +1,10 @@

- Sentry + + + + Sentry +

diff --git a/packages/replay-canvas/package.json b/packages/replay-canvas/package.json index 01f19b033a28..09da13c9c23b 100644 --- a/packages/replay-canvas/package.json +++ b/packages/replay-canvas/package.json @@ -58,7 +58,8 @@ }, "homepage": "https://docs.sentry.io/platforms/javascript/session-replay/", "devDependencies": { - "@sentry/rrweb": "2.44.1" + "@sentry-internal/rollup-utils": "11.9.0", + "@sentry/rrweb": "2.45.0" }, "dependencies": { "@sentry/replay": "11.9.0", @@ -66,8 +67,5 @@ }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/packages/replay-internal/README.md b/packages/replay-internal/README.md index fcd27ef14bd1..3a9f0ae3b6bb 100644 --- a/packages/replay-internal/README.md +++ b/packages/replay-internal/README.md @@ -1,6 +1,10 @@

- Sentry + + + + Sentry +

diff --git a/packages/replay-internal/package.json b/packages/replay-internal/package.json index 2f17ef06dc85..b5f133420845 100644 --- a/packages/replay-internal/package.json +++ b/packages/replay-internal/package.json @@ -73,8 +73,10 @@ "devDependencies": { "@babel/core": "^7.29.6", "@sentry-internal/replay-worker": "11.9.0", - "@sentry/rrweb": "2.44.1", - "@sentry/rrweb-snapshot": "2.44.1", + "@sentry-internal/rollup-utils": "11.9.0", + "@sentry/conventions": "^0.29.0", + "@sentry/rrweb": "2.45.0", + "@sentry/rrweb-snapshot": "2.45.0", "fflate": "0.8.3", "jest-matcher-utils": "^29.0.0", "jsdom-worker": "^0.3.0" @@ -85,8 +87,5 @@ }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/packages/replay-internal/src/coreHandlers/handleClick.ts b/packages/replay-internal/src/coreHandlers/handleClick.ts index f4576c3a7ca7..c61278494646 100644 --- a/packages/replay-internal/src/coreHandlers/handleClick.ts +++ b/packages/replay-internal/src/coreHandlers/handleClick.ts @@ -183,10 +183,10 @@ export class ClickDetector implements ReplayClickDetector { const now = nowInSeconds(); this._clicks.forEach(click => { - if (!click.mutationAfter && this._lastMutation) { + if (click.mutationAfter === undefined && this._lastMutation) { click.mutationAfter = click.timestamp <= this._lastMutation ? this._lastMutation - click.timestamp : undefined; } - if (!click.scrollAfter && this._lastScroll) { + if (click.scrollAfter === undefined && this._lastScroll) { click.scrollAfter = click.timestamp <= this._lastScroll ? this._lastScroll - click.timestamp : undefined; } @@ -215,8 +215,9 @@ export class ClickDetector implements ReplayClickDetector { /** Generate matching breadcrumb(s) for the click. */ private _generateBreadcrumbs(click: Click): void { const replay = this._replay; - const hadScroll = click.scrollAfter && click.scrollAfter <= this._scrollTimeout; - const hadMutation = click.mutationAfter && click.mutationAfter <= this._threshold; + // A mutation or scroll in the same millisecond as the click gives `0`, which still counts + const hadScroll = click.scrollAfter !== undefined && click.scrollAfter <= this._scrollTimeout; + const hadMutation = click.mutationAfter !== undefined && click.mutationAfter <= this._threshold; const isSlowClick = !hadScroll && !hadMutation; const { clickCount, clickBreadcrumb } = click; diff --git a/packages/replay-internal/src/coreHandlers/handleDom.ts b/packages/replay-internal/src/coreHandlers/handleDom.ts index 4c3171aa3276..468080132715 100644 --- a/packages/replay-internal/src/coreHandlers/handleDom.ts +++ b/packages/replay-internal/src/coreHandlers/handleDom.ts @@ -4,7 +4,7 @@ import type { Breadcrumb } from '@sentry/core'; import { record } from '@sentry/rrweb'; import type { serializedElementNodeWithId, serializedNodeWithId } from '@sentry/rrweb-snapshot'; import { NodeType } from '@sentry/rrweb-snapshot'; -import type { ReplayContainer } from '../types'; +import type { RecordingOptions, ReplayContainer } from '../types'; import { createBreadcrumb } from '../util/createBreadcrumb'; import { handleClick } from './handleClick'; import { addBreadcrumbEvent } from './util/addBreadcrumbEvent'; @@ -19,7 +19,7 @@ export const handleDomListener: (replay: ReplayContainer) => (handlerData: Handl return; } - const result = handleDom(handlerData); + const result = handleDom(handlerData, replay.getMaskAttributeFn()); if (!result) { return; @@ -75,8 +75,11 @@ export function getBaseDomBreadcrumb(target: Node | null, message: string): Brea * An event handler to react to DOM events. * Exported for tests. */ -export function handleDom(handlerData: HandlerDataDom): Breadcrumb | null { - const { target, message } = getDomTarget(handlerData); +export function handleDom( + handlerData: HandlerDataDom, + maskAttributeFn?: RecordingOptions['maskAttributeFn'], +): Breadcrumb | null { + const { target, message } = getDomTarget(handlerData, maskAttributeFn); return createBreadcrumb({ category: `ui.${handlerData.name}`, @@ -84,7 +87,10 @@ export function handleDom(handlerData: HandlerDataDom): Breadcrumb | null { }); } -function getDomTarget(handlerData: HandlerDataDom): { target: Node | null; message: string } { +function getDomTarget( + handlerData: HandlerDataDom, + maskAttributeFn?: RecordingOptions['maskAttributeFn'], +): { target: Node | null; message: string } { const isClick = handlerData.name === 'click'; let message: string | undefined; @@ -93,7 +99,7 @@ function getDomTarget(handlerData: HandlerDataDom): { target: Node | null; messa // Accessing event.target can throw (see getsentry/raven-js#838, #768) try { target = isClick ? getClickTargetNode(handlerData.event as Event) : getTargetNode(handlerData.event as Event); - message = htmlTreeAsString(target, { maxStringLength: 200 }) || ''; + message = htmlTreeAsString(target, { maxStringLength: 200, maskAttributeFn }) || ''; } catch { message = ''; } diff --git a/packages/replay-internal/src/coreHandlers/handleKeyboardEvent.ts b/packages/replay-internal/src/coreHandlers/handleKeyboardEvent.ts index aba74d6c8abc..4fc1d3553089 100644 --- a/packages/replay-internal/src/coreHandlers/handleKeyboardEvent.ts +++ b/packages/replay-internal/src/coreHandlers/handleKeyboardEvent.ts @@ -1,6 +1,6 @@ import { htmlTreeAsString } from '@sentry/browser-utils'; import type { Breadcrumb } from '@sentry/core'; -import type { ReplayContainer } from '../types'; +import type { RecordingOptions, ReplayContainer } from '../types'; import { createBreadcrumb } from '../util/createBreadcrumb'; import { getBaseDomBreadcrumb } from './handleDom'; import { addBreadcrumbEvent } from './util/addBreadcrumbEvent'; @@ -16,7 +16,7 @@ export function handleKeyboardEvent(replay: ReplayContainer, event: KeyboardEven // session with a single "keydown" breadcrumb is created) replay.updateUserActivity(); - const breadcrumb = getKeyboardBreadcrumb(event); + const breadcrumb = getKeyboardBreadcrumb(event, replay.getMaskAttributeFn()); if (!breadcrumb) { return; @@ -26,7 +26,10 @@ export function handleKeyboardEvent(replay: ReplayContainer, event: KeyboardEven } /** exported only for tests */ -export function getKeyboardBreadcrumb(event: KeyboardEvent): Breadcrumb | null { +export function getKeyboardBreadcrumb( + event: KeyboardEvent, + maskAttributeFn?: RecordingOptions['maskAttributeFn'], +): Breadcrumb | null { const { metaKey, shiftKey, ctrlKey, altKey, key, target } = event; // never capture for input fields @@ -44,7 +47,7 @@ export function getKeyboardBreadcrumb(event: KeyboardEvent): Breadcrumb | null { return null; } - const message = htmlTreeAsString(target, { maxStringLength: 200 }) || ''; + const message = htmlTreeAsString(target, { maxStringLength: 200, maskAttributeFn }) || ''; const baseBreadcrumb = getBaseDomBreadcrumb(target as Node, message); return createBreadcrumb({ diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts index 6d6ec213b653..b02133abdd0d 100644 --- a/packages/replay-internal/src/replay.ts +++ b/packages/replay-internal/src/replay.ts @@ -306,6 +306,10 @@ export class ReplayContainer implements ReplayContainerInterface { return this._options; } + public getMaskAttributeFn(): RecordingOptions['maskAttributeFn'] { + return this._recordingOptions.maskAttributeFn; + } + /** A wrapper to conditionally capture exceptions. */ public handleException(error: unknown): void { DEBUG_BUILD && debug.exception(error); diff --git a/packages/replay-internal/src/types/replay.ts b/packages/replay-internal/src/types/replay.ts index 955393c2f225..f3a275ad95aa 100644 --- a/packages/replay-internal/src/types/replay.ts +++ b/packages/replay-internal/src/types/replay.ts @@ -538,6 +538,7 @@ export interface ReplayContainer { updateUserActivity(): void; addUpdate(cb: AddUpdateCallback): void; getOptions(): ReplayPluginOptions; + getMaskAttributeFn(): RecordingOptions['maskAttributeFn']; getSessionId(): string | undefined; checkAndHandleExpiredSession(): boolean | void; setInitialState(): void; diff --git a/packages/replay-internal/src/types/rrweb.ts b/packages/replay-internal/src/types/rrweb.ts index c6ca1333918d..caacbb515146 100644 --- a/packages/replay-internal/src/types/rrweb.ts +++ b/packages/replay-internal/src/types/rrweb.ts @@ -37,6 +37,7 @@ export type ReplayEventWithTime = { export type RrwebRecordOptions = { maskAllText?: boolean; maskAllInputs?: boolean; + maskAttributeFn?: (key: string, value: string, el: HTMLElement) => string; blockClass?: ClassOption; ignoreClass?: string; maskTextClass?: ClassOption; diff --git a/packages/replay-internal/test/integration/breadcrumbAttributeMasking.test.ts b/packages/replay-internal/test/integration/breadcrumbAttributeMasking.test.ts new file mode 100644 index 000000000000..c282e6f2ce7c --- /dev/null +++ b/packages/replay-internal/test/integration/breadcrumbAttributeMasking.test.ts @@ -0,0 +1,158 @@ +/** + * @vitest-environment jsdom + */ + +import '../utils/mock-internal-setTimeout'; +import type { Breadcrumb } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Replay } from '../../src/integration'; +import type { ReplayConfiguration } from '../../src/types'; +import { resetSdkMock } from '../mocks/resetSdkMock'; + +describe('Integration | breadcrumb attribute masking', () => { + let integration: Replay; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(async () => { + await integration?.stop(); + vi.clearAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe.each(['data-sentry-component', 'data-sentry-element'])('%s', attribute => { + it.each([ + { + label: 'configured masking', + options: { maskAttributes: [attribute] }, + expected: '******* *** > *** *****', + }, + { + label: 'explicit unmasking', + options: { maskAttributes: [attribute], unmask: ['.sentry-unmask'] }, + expected: 'Account PIN > Pay Alice', + }, + { + label: 'default masking', + options: {}, + expected: 'Account PIN > Pay Alice', + }, + ])('honors $label for annotated target and ancestor breadcrumbs', async ({ options, expected }) => { + const breadcrumbs: Breadcrumb[] = []; + const sdk = await resetSdkMock({ + replayOptions: { + ...options, + beforeAddRecordingEvent: event => { + if (event.data.tag === 'breadcrumb') { + breadcrumbs.push(event.data.payload); + } + return event; + }, + }, + }); + integration = sdk.integration; + const parent = document.createElement('div'); + parent.className = 'sentry-unmask'; + parent.setAttribute(attribute, 'Account PIN'); + const target = document.createElement('button'); + target.className = 'sentry-unmask'; + target.setAttribute(attribute, 'Pay Alice'); + parent.appendChild(target); + const event = new Event('click'); + target.dispatchEvent(event); + + sdk.domHandler({ name: 'click', event }); + await vi.runAllTimersAsync(); + + expect(breadcrumbs).toHaveLength(1); + expect(breadcrumbs[0]?.message).toBe(expected); + }); + }); + + it.each(['click', 'keypress', 'keydown'])('masks target and ancestor attributes in %s breadcrumbs', async name => { + const breadcrumbs: Breadcrumb[] = []; + const sdk = await resetSdkMock({ + replayOptions: { + beforeAddRecordingEvent: event => { + if (event.data.tag === 'breadcrumb') { + breadcrumbs.push(event.data.payload); + } + return event; + }, + }, + }); + integration = sdk.integration; + const parent = document.createElement('div'); + parent.title = 'Account PIN'; + const target = document.createElement('button'); + target.setAttribute('aria-label', 'Pay Alice'); + target.setAttribute('type', 'button'); + parent.appendChild(target); + const event = new KeyboardEvent(name, { key: 'Escape' }); + target.dispatchEvent(event); + + if (name === 'keydown') { + sdk.replay['_handleKeyboardEvent'](event); + } else { + sdk.domHandler({ name, event }); + } + await vi.runAllTimersAsync(); + + expect(breadcrumbs).toHaveLength(1); + expect(breadcrumbs[0]?.message).toBe('div[title="******* ***"] > button[aria-label="*** *****"][type="button"]'); + }); + + it.each([ + { + label: 'custom attributes with text masking disabled', + options: { maskAttributes: ['alt', 'name', 'id', 'class'], maskAllText: false }, + expected: 'button#******.*******[aria-label="Pay Alice"][name="*******"][title="Account PIN"][alt="*****"]', + }, + { + label: 'explicit unmasking', + options: { unmask: ['.sentry-unmask'] }, + expected: + 'button#secret.private.sentry-unmask[aria-label="Pay Alice"][name="account"][title="Account PIN"][alt="Alice"]', + }, + { + label: 'an empty attribute mask list', + options: { maskAttributes: [] }, + expected: 'button#secret.private[aria-label="Pay Alice"][name="account"][title="Account PIN"][alt="Alice"]', + }, + ])('honors $label in click breadcrumbs', async ({ options, expected }) => { + const breadcrumbs: Breadcrumb[] = []; + const sdk = await resetSdkMock({ + replayOptions: { + ...options, + beforeAddRecordingEvent: event => { + if (event.data.tag === 'breadcrumb') { + breadcrumbs.push(event.data.payload); + } + return event; + }, + } satisfies ReplayConfiguration, + }); + integration = sdk.integration; + const target = document.createElement('button'); + target.id = 'secret'; + target.className = 'private'; + target.setAttribute('aria-label', 'Pay Alice'); + target.setAttribute('name', 'account'); + target.title = 'Account PIN'; + target.setAttribute('alt', 'Alice'); + if ('unmask' in options) { + target.classList.add('sentry-unmask'); + } + const event = new Event('click'); + target.dispatchEvent(event); + + sdk.domHandler({ name: 'click', event }); + await vi.runAllTimersAsync(); + + expect(breadcrumbs).toHaveLength(1); + expect(breadcrumbs[0]?.message).toBe(expected); + }); +}); diff --git a/packages/replay-internal/test/unit/coreHandlers/handleClick.test.ts b/packages/replay-internal/test/unit/coreHandlers/handleClick.test.ts index 66beddf6ab19..7a51c5ca3e5b 100644 --- a/packages/replay-internal/test/unit/coreHandlers/handleClick.test.ts +++ b/packages/replay-internal/test/unit/coreHandlers/handleClick.test.ts @@ -321,6 +321,22 @@ describe('Unit | coreHandlers | handleClick', () => { expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0); }); + test('it does not consider clicks with a mutation in the same millisecond as slow click', async () => { + const breadcrumb: Breadcrumb = { + timestamp: BASE_TIMESTAMP / 1000, + data: { + nodeId: 1, + }, + }; + const node = document.createElement('button'); + detector.handleClick(breadcrumb, node); + detector.registerMutation(BASE_TIMESTAMP); + + vi.advanceTimersByTime(3_500); + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0); + }); + test('it considers clicks with mutation after threshold as slow click', async () => { const breadcrumb: Breadcrumb = { timestamp: BASE_TIMESTAMP / 1000, @@ -429,6 +445,22 @@ describe('Unit | coreHandlers | handleClick', () => { ); }); + test('it does not consider clicks with a scroll in the same millisecond as slow click', async () => { + const breadcrumb: Breadcrumb = { + timestamp: BASE_TIMESTAMP / 1000, + data: { + nodeId: 1, + }, + }; + const node = document.createElement('button'); + detector.handleClick(breadcrumb, node); + detector.registerScroll(BASE_TIMESTAMP); + + vi.advanceTimersByTime(3_500); + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0); + }); + test('it does not consider clicks with scroll before threshold as slow click', async () => { const breadcrumb: Breadcrumb = { timestamp: BASE_TIMESTAMP / 1000, diff --git a/packages/replay-internal/test/unit/eventBuffer/EventBufferCompressionWorker.test.ts b/packages/replay-internal/test/unit/eventBuffer/EventBufferCompressionWorker.test.ts index 1cfcd1cc83b1..826542408d46 100644 --- a/packages/replay-internal/test/unit/eventBuffer/EventBufferCompressionWorker.test.ts +++ b/packages/replay-internal/test/unit/eventBuffer/EventBufferCompressionWorker.test.ts @@ -12,6 +12,11 @@ import { BASE_TIMESTAMP } from '../..'; import { decompress } from '../../utils/compression'; import { getTestEventIncremental } from '../../utils/getTestEvent'; +vi.mock('../../../src/constants', async importOriginal => ({ + ...(await importOriginal()), + REPLAY_MAX_EVENT_BUFFER_SIZE: 100_000, +})); + const TEST_EVENT = getTestEventIncremental({ timestamp: BASE_TIMESTAMP }); describe('Unit | eventBuffer | EventBufferCompressionWorker', () => { @@ -152,7 +157,7 @@ describe('Unit | eventBuffer | EventBufferCompressionWorker', () => { await expect(() => buffer.addEvent({ data: { o: 3 }, timestamp: BASE_TIMESTAMP, type: 3 })).rejects.toBeDefined(); }); - describe('size limit', { timeout: 10_000 }, () => { + describe('size limit', () => { it('rejects if size exceeds limit', async function () { const buffer = createEventBuffer({ useCompression: true, diff --git a/packages/replay-worker/README.md b/packages/replay-worker/README.md index 486878cd9192..8d5080239dab 100644 --- a/packages/replay-worker/README.md +++ b/packages/replay-worker/README.md @@ -1,6 +1,10 @@

- Sentry + + + + Sentry +

diff --git a/packages/replay-worker/package.json b/packages/replay-worker/package.json index a4c6b3d61ac2..48b2bf218f48 100644 --- a/packages/replay-worker/package.json +++ b/packages/replay-worker/package.json @@ -59,8 +59,5 @@ }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/packages/rollup-plugin/README.md b/packages/rollup-plugin/README.md index 17a8d1c7b2c2..c87678246013 100644 --- a/packages/rollup-plugin/README.md +++ b/packages/rollup-plugin/README.md @@ -1,6 +1,10 @@

- Sentry + + + + Sentry +

diff --git a/packages/rollup-plugin/package.json b/packages/rollup-plugin/package.json index 6a26219a4038..c019b59372a4 100644 --- a/packages/rollup-plugin/package.json +++ b/packages/rollup-plugin/package.json @@ -51,6 +51,9 @@ "dependencies": { "@sentry/bundler-plugins": "11.9.0" }, + "devDependencies": { + "@sentry-internal/rollup-utils": "11.9.0" + }, "peerDependencies": { "rollup": ">=3.2.0" }, @@ -58,8 +61,5 @@ "rollup": { "optional": true } - }, - "volta": { - "extends": "../../package.json" } } diff --git a/packages/server-runtime-injection/README.md b/packages/server-runtime-injection/README.md index 6ad8c1d61ac5..b5ae207baf30 100644 --- a/packages/server-runtime-injection/README.md +++ b/packages/server-runtime-injection/README.md @@ -1,6 +1,10 @@

- Sentry + + + + Sentry +

diff --git a/packages/server-runtime-injection/package.json b/packages/server-runtime-injection/package.json index 0ea680b9b8f9..13c52608e8dd 100644 --- a/packages/server-runtime-injection/package.json +++ b/packages/server-runtime-injection/package.json @@ -51,6 +51,7 @@ "devDependencies": { "@apm-js-collab/code-transformer": "^0.18.1", "@apm-js-collab/tracing-hooks": "^0.13.3", + "@sentry-internal/rollup-utils": "11.9.0", "@types/node": "^20.19.0", "@vercel/nft": "^1.11.0", "meriyah": "^6.1.4", @@ -75,9 +76,6 @@ "test:watch": "vitest --watch", "yalc:publish": "yalc publish --push --sig" }, - "volta": { - "extends": "../../package.json" - }, "sideEffects": [ "**/import-hook.mjs" ], diff --git a/packages/server-utils/.oxlintrc.json b/packages/server-utils/.oxlintrc.json index d9e07c9dd0cd..001bf4e0b298 100644 --- a/packages/server-utils/.oxlintrc.json +++ b/packages/server-utils/.oxlintrc.json @@ -31,6 +31,16 @@ "name": "diagnostics_channel", "allowTypeImports": true, "message": "Bun garbage-collects a channel that no code references, together with its subscribers. Import the wrappers from `src/utils/diagnosticsChannel.ts` instead. See https://github.com/oven-sh/bun/issues/43086" + }, + { + "name": "hono", + "message": "`@sentry/server-utils` has no `hono` dependency and must work on every Hono major. Use the vendored types in `src/integrations/hono/honoTypes.ts`." + } + ], + "patterns": [ + { + "group": ["hono/*"], + "message": "`@sentry/server-utils` has no `hono` dependency and must work on every Hono major. Use the vendored types in `src/integrations/hono/honoTypes.ts`." } ] } diff --git a/packages/server-utils/README.md b/packages/server-utils/README.md index 913702972808..f9ea5f839e5d 100644 --- a/packages/server-utils/README.md +++ b/packages/server-utils/README.md @@ -1,6 +1,10 @@

- Sentry + + + + Sentry +

diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index 2e7ae63b6161..5ac7402840b8 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -97,15 +97,17 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/core": "11.9.0" }, "devDependencies": { "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.4", "@neondatabase/serverless": "1.2.0", + "@sentry-internal/rollup-utils": "11.9.0", "@types/node": "^20.19.0", "meriyah": "^6.1.4", - "vite": "^8.3.1" + "vite": "^8.3.1", + "webpack": "^5.104.1" }, "scripts": { "build": "run-p build:transpile build:types", @@ -126,9 +128,6 @@ "test:watch": "vitest --watch", "yalc:publish": "yalc publish --push --sig" }, - "volta": { - "extends": "../../package.json" - }, "sideEffects": false, "nx": { "targets": { diff --git a/packages/server-utils/src/ai/anthropic-ai/index.ts b/packages/server-utils/src/ai/anthropic-ai/index.ts index 199649884f62..5723adf1fde3 100644 --- a/packages/server-utils/src/ai/anthropic-ai/index.ts +++ b/packages/server-utils/src/ai/anthropic-ai/index.ts @@ -27,7 +27,7 @@ import { } from '../core/utils'; import { ANTHROPIC_AI_ORIGIN, ANTHROPIC_METHOD_REGISTRY } from './constants'; import { instrumentAsyncIterableStream, instrumentMessageStream } from './streaming'; -import { instrumentToolRunnerMethod } from './tool-runner'; +import { instrumentSessionToolRunner, instrumentToolRunner, instrumentToolRunnerFactory } from './tool-runner'; import type { AnthropicAiOptions, AnthropicAiResponse, AnthropicAiStreamingEvent, ContentBlock } from './types'; import { handleResponseError, messagesFromParams, setMessagesAttribute } from './utils'; @@ -351,9 +351,12 @@ function instrumentClientInPlace(client: T, options: Anthropic owner[methodName] = instrumented; } - const betaMessages = (client as { beta?: { messages?: object } }).beta?.messages; - if (betaMessages) { - instrumentToolRunnerMethod(betaMessages, options); + const beta = (client as { beta?: { messages?: object; sessions?: { events?: object } } }).beta; + if (beta?.messages) { + instrumentToolRunnerFactory(beta.messages, instrumentToolRunner, options); + } + if (beta?.sessions?.events) { + instrumentToolRunnerFactory(beta.sessions.events, instrumentSessionToolRunner, options); } return client; diff --git a/packages/server-utils/src/ai/anthropic-ai/streaming.ts b/packages/server-utils/src/ai/anthropic-ai/streaming.ts index 20507ed4f3bf..1c3ed58674d6 100644 --- a/packages/server-utils/src/ai/anthropic-ai/streaming.ts +++ b/packages/server-utils/src/ai/anthropic-ai/streaming.ts @@ -16,7 +16,9 @@ interface StreamingState { responseId: string; /** The model name. */ responseModel: string; - /** Number of prompt/input tokens used. */ + /** Number of input tokens, not counting cache reads and writes. */ + inputTokens: number | undefined; + /** Number of prompt/input tokens used, including cache reads and writes. */ promptTokens: number | undefined; /** Number of completion/output tokens used. */ completionTokens: number | undefined; @@ -68,8 +70,12 @@ function handleMessageMetadata(event: AnthropicAiStreamingEvent, state: Streamin // Cumulative token counts and the final stop reason both arrive on the message_delta event. // @see https://docs.anthropic.com/en/docs/build-with-claude/streaming#event-types if (event.type === 'message_delta') { - if (event.usage && typeof event.usage.output_tokens === 'number') { - state.completionTokens = event.usage.output_tokens; + if (event.usage) { + // Input and cache counts can grow after message_start (e.g. with server tools), so the last value wins. + applyInputUsage(event.usage, state); + if (typeof event.usage.output_tokens === 'number') { + state.completionTokens = event.usage.output_tokens; + } } if (event.delta?.stop_reason) { state.finishReasons.push(event.delta.stop_reason); @@ -83,20 +89,32 @@ function handleMessageMetadata(event: AnthropicAiStreamingEvent, state: Streamin if (message.model) state.responseModel = message.model; if (message.usage) { - if (typeof message.usage.input_tokens === 'number') { - state.promptTokens = - message.usage.input_tokens + - (message.usage.cache_creation_input_tokens ?? 0) + - (message.usage.cache_read_input_tokens ?? 0); - } - if (typeof message.usage.cache_creation_input_tokens === 'number') - state.cacheCreationInputTokens = message.usage.cache_creation_input_tokens; - if (typeof message.usage.cache_read_input_tokens === 'number') - state.cacheReadInputTokens = message.usage.cache_read_input_tokens; + applyInputUsage(message.usage, state); } } } +/** + * Records the input and cache token counts of a usage object that are numbers. + */ +function applyInputUsage( + usage: { + input_tokens?: number | null; + cache_creation_input_tokens?: number | null; + cache_read_input_tokens?: number | null; + }, + state: StreamingState, +): void { + if (typeof usage.input_tokens === 'number') state.inputTokens = usage.input_tokens; + if (typeof usage.cache_creation_input_tokens === 'number') + state.cacheCreationInputTokens = usage.cache_creation_input_tokens; + if (typeof usage.cache_read_input_tokens === 'number') state.cacheReadInputTokens = usage.cache_read_input_tokens; + + if (typeof state.inputTokens === 'number') { + state.promptTokens = state.inputTokens + (state.cacheCreationInputTokens ?? 0) + (state.cacheReadInputTokens ?? 0); + } +} + /** * Handle start of a content block (e.g., tool_use) */ @@ -215,6 +233,7 @@ export async function* instrumentAsyncIterableStream( finishReasons: [], responseId: '', responseModel: '', + inputTokens: undefined, promptTokens: undefined, completionTokens: undefined, cacheCreationInputTokens: undefined, @@ -246,6 +265,7 @@ export function instrumentMessageStream finishReasons: [], responseId: '', responseModel: '', + inputTokens: undefined, promptTokens: undefined, completionTokens: undefined, cacheCreationInputTokens: undefined, diff --git a/packages/server-utils/src/ai/anthropic-ai/tool-runner.ts b/packages/server-utils/src/ai/anthropic-ai/tool-runner.ts index 795b17cffef1..b15a62630eed 100644 --- a/packages/server-utils/src/ai/anthropic-ai/tool-runner.ts +++ b/packages/server-utils/src/ai/anthropic-ai/tool-runner.ts @@ -26,12 +26,17 @@ type AnyFunction = (...args: unknown[]) => unknown; type RunnableTool = { name: string; description?: unknown }; type RunContext = { toolUse?: { id?: unknown }; toolUseBlock?: { id?: unknown }; signal?: AbortSignal } | undefined; type ToolRunner = { params?: { tools?: unknown } }; +type SessionToolRunner = { tools?: unknown }; -/** Wrap `beta.messages.toolRunner()` so the runners it returns trace their tool calls. */ -export function instrumentToolRunnerMethod(messages: object, options: AnthropicAiOptions): void { - wrapInPlace(messages, 'toolRunner', (toolRunner, thisArg, args) => { +/** Wrap the `toolRunner()` method of `owner` so the runners it returns trace their tool calls. */ +export function instrumentToolRunnerFactory( + owner: object, + instrumentRunner: (runner: unknown, options: AnthropicAiOptions) => void, + options: AnthropicAiOptions, +): void { + wrapInPlace(owner, 'toolRunner', (toolRunner, thisArg, args) => { const runner = toolRunner.apply(thisArg, args); - instrumentToolRunner(runner, options); + instrumentRunner(runner, options); return runner; }); } @@ -56,6 +61,16 @@ export function instrumentToolRunner(runner: unknown, options: AnthropicAiOption }); } +/** Give each tool call of a Managed Agents session runner a `gen_ai.execute_tool` span. */ +export function instrumentSessionToolRunner(runner: unknown, options: AnthropicAiOptions): void { + if (!runner || typeof runner !== 'object') { + return; + } + + // The session runner has no `addTools()`: it only runs the tools it was created with. + instrumentTools((runner as SessionToolRunner).tools, options); +} + function instrumentTools(tools: unknown, options: AnthropicAiOptions): void { if (!Array.isArray(tools)) { return; diff --git a/packages/server-utils/src/ai/anthropic-ai/types.ts b/packages/server-utils/src/ai/anthropic-ai/types.ts index bb768e63063e..ddee39046d57 100644 --- a/packages/server-utils/src/ai/anthropic-ai/types.ts +++ b/packages/server-utils/src/ai/anthropic-ai/types.ts @@ -96,6 +96,10 @@ export type AnthropicAiStreamingEvent = { }; usage?: { output_tokens: number; // Final total output tokens; emitted on the last `message_delta` event + // Cumulative input counts, which can be larger than at `message_start` + input_tokens?: number | null; + cache_creation_input_tokens?: number | null; + cache_read_input_tokens?: number | null; }; message?: AnthropicAiMessage; /** Present for fine-grained tool streaming */ diff --git a/packages/server-utils/src/ai/flue/index.ts b/packages/server-utils/src/ai/flue/index.ts index 44c9ade4667c..d4856f95f2ac 100644 --- a/packages/server-utils/src/ai/flue/index.ts +++ b/packages/server-utils/src/ai/flue/index.ts @@ -1,5 +1,15 @@ -import type { Span } from '@sentry/core'; -import { continueTrace, getActiveSpan, LRUMap, startSpan, withActiveSpan } from '@sentry/core'; +import type { Scope, Span } from '@sentry/core'; +import { + continueTrace, + getActiveSpan, + getDefaultIsolationScope, + getIsolationScope, + GLOBAL_OBJ, + LRUMap, + startNewTrace, + startSpan, + withActiveSpan, +} from '@sentry/core'; import { GEN_AI_AGENT_NAME, GEN_AI_CONVERSATION_ID, @@ -9,6 +19,7 @@ import { import type { GenAiOptions } from '../core/utils'; import { getGenAiSpanOp, resolveAIRecordingOptions } from '../core/utils'; import { skipPiAiProviderIntegrations } from '../pi-ai/providers'; +import { withCleanScopes } from '../../utils/withCleanScopes'; import { FLUE_INSTRUMENTATION_KEY, FLUE_OPERATION, FLUE_ORIGIN, MAX_TRACKED_FLUE_SPANS } from './constants'; import type { SpanTracker } from './utils'; import { @@ -23,6 +34,39 @@ import type { FlueInstrumentation } from './types'; export type FlueOptions = GenAiOptions; +interface Admission { + isolationScope: Scope; + span: Span | undefined; +} + +/** + * Flue's Node coordinator runs every submission from one long-lived claim loop, started by the + * first admission and keeping that call's async context for the life of the process. Left alone, + * every agent run lands in the trace of whichever request admitted the first one + * (https://github.com/withastro/flue/issues/813). + */ +function runInAdmissionContext(admission: Admission | undefined, next: () => T): T { + if (isCloudflareWorkers()) { + return next(); + } + if (!admission) { + // Admitted by another process, replayed by idempotency key, or evicted: there is nothing to + // restore, but the loop's context still belongs to an unrelated request. + return withCleanScopes(getDefaultIsolationScope().clone(), () => startNewTrace(next)); + } + return withCleanScopes(admission.isolationScope, () => + admission.span ? withActiveSpan(admission.span, next) : startNewTrace(next), + ); +} + +/** + * Cloudflare runs each turn from a Durable Object alarm, which already starts a trace of its own, so + * there is no leaked context to undo there. + */ +function isCloudflareWorkers(): boolean { + return GLOBAL_OBJ.navigator?.userAgent === 'Cloudflare-Workers'; +} + /** * Build the object to hand to `instrument()` from `@flue/runtime`. * @@ -56,6 +100,8 @@ export function createFlueInstrumentation(options: FlueOptions = {}): FlueInstru // Capped, unlike the above: these are keyed off ids that only a matching end observation removes. const turnSpans: SpanTracker = new LRUMap(MAX_TRACKED_FLUE_SPANS); const toolSpans: SpanTracker = new LRUMap(MAX_TRACKED_FLUE_SPANS); + // Capped: a submission admitted here but run by another process never settles here. + const admissions = new LRUMap(MAX_TRACKED_FLUE_SPANS); return { key: FLUE_INSTRUMENTATION_KEY, @@ -80,9 +126,13 @@ export function createFlueInstrumentation(options: FlueOptions = {}): FlueInstru // A submission opens with a wrapper operation whose id *is* the submission id; the run it // wraps gets its own operation id, and that is the one the observations reference. Spanning - // the wrapper too would double-count every agent invocation. + // the wrapper too would double-count every agent invocation, but it is the outermost point of + // every attempt, so it is where the admitting context goes back in. const operationId = operation.operationId; - if (!operationId || operationId === ctx.submissionId || agentSpans.has(operationId)) { + if (operationId && operationId === ctx.submissionId) { + return runInAdmissionContext(admissions.get(operationId), next); + } + if (!operationId || agentSpans.has(operationId)) { return next(); } @@ -148,6 +198,20 @@ export function createFlueInstrumentation(options: FlueOptions = {}): FlueInstru } switch (observation.type) { + // Emitted synchronously from inside the admitting call, so the active context is its own. + case 'submission_queued': + if (observation.submissionId && !isCloudflareWorkers()) { + admissions.set(observation.submissionId, { + isolationScope: getIsolationScope(), + span: getActiveSpan(), + }); + } + return; + case 'submission_settled': + if (observation.submissionId) { + admissions.remove(observation.submissionId); + } + return; case 'turn_start': startTurnSpan(observation, turnSpans); return; @@ -179,6 +243,7 @@ export function createFlueInstrumentation(options: FlueOptions = {}): FlueInstru span.end(); } agentSpans.clear(); + admissions.clear(); turnSpans.clear(); toolSpans.clear(); }, diff --git a/packages/server-utils/src/ai/google-genai/index.ts b/packages/server-utils/src/ai/google-genai/index.ts index 16dbb01d98f9..e8c002a921f8 100644 --- a/packages/server-utils/src/ai/google-genai/index.ts +++ b/packages/server-utils/src/ai/google-genai/index.ts @@ -378,9 +378,19 @@ function instrumentMethod( * Recursively instruments methods and handles special cases like chats.create */ function createDeepProxy(target: T, currentPath = '', options: GoogleGenAIOptions): T { + const propertyCache = new Map(); + return new Proxy(target, { get: (t, prop, receiver) => { const value = Reflect.get(t, prop, receiver); + const cachedEntry = propertyCache.get(prop); + if (cachedEntry) { + if (cachedEntry.original === value) { + return cachedEntry.wrapped; + } + propertyCache.delete(prop); + } + const methodPath = buildMethodPath(currentPath, String(prop)); const instrumentedMethod: InstrumentedMethodEntry | undefined = @@ -392,6 +402,7 @@ function createDeepProxy(target: T, currentPath = '', options: : value.bind(t); if (!instrumentedMethod.proxyResultPath) { + propertyCache.set(prop, { original: value, wrapped: wrappedMethod }); return wrappedMethod; } @@ -399,22 +410,28 @@ function createDeepProxy(target: T, currentPath = '', options: // Note: This currently only properly handles synchronous methods. For async methods, // the Promise itself would be proxied instead of the resolved value. Currently we // don't have a case where this is needed, so I'll keep it simple for now. - return function (...args: unknown[]): unknown { + const methodWithProxiedResult = function (...args: unknown[]): unknown { const result = wrappedMethod(...args); if (result && typeof result === 'object') { return createDeepProxy(result as object, instrumentedMethod.proxyResultPath, options); } return result; }; + propertyCache.set(prop, { original: value, wrapped: methodWithProxiedResult }); + return methodWithProxiedResult; } if (typeof value === 'function') { // Bind non-instrumented functions to preserve the original `this` context - return value.bind(t); + const boundMethod = value.bind(t); + propertyCache.set(prop, { original: value, wrapped: boundMethod }); + return boundMethod; } if (value && typeof value === 'object') { - return createDeepProxy(value, methodPath, options); + const nestedProxy = createDeepProxy(value, methodPath, options); + propertyCache.set(prop, { original: value, wrapped: nestedProxy }); + return nestedProxy; } return value; diff --git a/packages/server-utils/src/ai/langchain/constants.ts b/packages/server-utils/src/ai/langchain/constants.ts index cce2d6a46065..fd0027343fde 100644 --- a/packages/server-utils/src/ai/langchain/constants.ts +++ b/packages/server-utils/src/ai/langchain/constants.ts @@ -1,5 +1,7 @@ export const LANGCHAIN_INTEGRATION_NAME = 'LangChain' as const; export const LANGCHAIN_ORIGIN = 'auto.ai.langchain'; +// Matches `MAX_TRACKED_FLUE_SPANS`: LangChain skips the end callbacks when a consumer abandons a stream. +export const MAX_TRACKED_LANGCHAIN_RUNS = 1000; export const ROLE_MAP: Record = { human: 'user', diff --git a/packages/server-utils/src/ai/langchain/index.ts b/packages/server-utils/src/ai/langchain/index.ts index b255ed68ea3f..e05ea6f38a81 100644 --- a/packages/server-utils/src/ai/langchain/index.ts +++ b/packages/server-utils/src/ai/langchain/index.ts @@ -1,9 +1,11 @@ /* eslint-disable max-lines */ import { + debug, getActiveSpan, getClient, getRootSpan, hasSpanStreamingEnabled, + LRUMap, SPAN_STATUS_ERROR, spanIsIgnored, spanToJSON, @@ -25,8 +27,9 @@ import { SENTRY_ORIGIN, } from '@sentry/conventions/attributes'; import { GEN_AI_CHAT, GEN_AI_EXECUTE_TOOL, GEN_AI_INVOKE_AGENT } from '@sentry/conventions/op'; +import { DEBUG_BUILD } from '../../debug-build'; import { resolveAIRecordingOptions } from '../core/utils'; -import { LANGCHAIN_ORIGIN } from './constants'; +import { LANGCHAIN_ORIGIN, MAX_TRACKED_LANGCHAIN_RUNS } from './constants'; import { addTypeSafeClassifierResponseAttributes, isTypeSafeClassifier, @@ -68,25 +71,53 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): const { recordInputs, recordOutputs } = resolveAIRecordingOptions(options); // Internal state - single instance tracks all spans - const runs = new Map(); + const runs = new LRUMap(MAX_TRACKED_LANGCHAIN_RUNS); const getRun = (runId: string | undefined): LangChainRun | undefined => (runId ? runs.get(runId) : undefined); /** * Exit a span and clean up */ const exitSpan = (runId: string): void => { - const span = runs.get(runId)?.span; + const span = runs.remove(runId)?.span; if (span?.isRecording()) { span.end(); } - runs.delete(runId); + }; + + // `LRUMap.set` would drop the oldest run without ending its span, so it is ended first, like Flue and Mastra. + let warnedFull = false; + const trackRun = (runId: string, run: LangChainRun): void => { + if (runs.size >= MAX_TRACKED_LANGCHAIN_RUNS) { + if (!warnedFull) { + warnedFull = true; + DEBUG_BUILD && + debug.warn( + `[LangChain] More than ${MAX_TRACKED_LANGCHAIN_RUNS} runs are open, ending the oldest. Its span may be cut short.`, + ); + } + const oldestRunId = runs.keys()[0]; + if (oldestRunId !== undefined) { + exitSpan(oldestRunId); + } + } + runs.set(runId, run); + }; + + // Nearest first. `LRUMap.get()` marks each run recently used, so a long-running root run is not evicted while + // its subtree is busy. + const getAncestors = (parentRunId: string | undefined): LangChainRun[] => { + const ancestors: LangChainRun[] = []; + for (let run = getRun(parentRunId); run; run = getRun(run.parentRunId)) { + ancestors.push(run); + } + return ancestors; }; // LangChain never makes our run spans active, so a run starts under its nearest ancestor run span. Like core, // an ignored span only parents when it is a root, which drops its subtree. The active span stays the parent // if the ancestor run has no span, or if another span was opened since that run started. - const getParentRunSpan = (parentRunId: string | undefined, activeSpan: Span | undefined): Span | undefined => { - for (let run = getRun(parentRunId); run; run = getRun(run.parentRunId)) { + const getParentRunSpan = (ancestors: LangChainRun[], activeSpan: Span | undefined): Span | undefined => { + for (const run of ancestors) { if (run.activeSpan !== activeSpan) { return undefined; } @@ -98,27 +129,27 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): }; const startRunSpan = (runId: string, parentRunId: string | undefined, start: () => Span): LangChainRun => { + const ancestors = getAncestors(parentRunId); const activeSpan = getActiveSpan(); - const parentSpan = getParentRunSpan(parentRunId, activeSpan); + const parentSpan = getParentRunSpan(ancestors, activeSpan); const span = parentSpan ? withActiveSpan(parentSpan, start) : start(); // `@langchain/core` >= 1.1.40 keeps `configurable` out of run metadata, so often only the chat span knows // the conversation id. The own id is read off the span, since one from `Sentry.setConversationId()` is // only set there. const ownId = spanToJSON(span).attributes[GEN_AI_CONVERSATION_ID]; - const conversationId = typeof ownId === 'string' ? ownId : getRun(parentRunId)?.conversationId; + const conversationId = typeof ownId === 'string' ? ownId : ancestors[0]?.conversationId; if (conversationId && conversationId !== ownId) { span.setAttribute(GEN_AI_CONVERSATION_ID, conversationId); } const run: LangChainRun = { span, parentRunId, activeSpan, conversationId }; - runs.set(runId, run); + trackRun(runId, run); if (conversationId) { - for ( - let ancestor = getRun(parentRunId); - ancestor && !ancestor.conversationId; - ancestor = getRun(ancestor.parentRunId) - ) { + for (const ancestor of ancestors) { + if (ancestor.conversationId) { + break; + } ancestor.conversationId = conversationId; ancestor.span.setAttribute(GEN_AI_CONVERSATION_ID, conversationId); } diff --git a/packages/server-utils/src/ai/openai/index.ts b/packages/server-utils/src/ai/openai/index.ts index a832688c8cb3..ada47a5710c7 100644 --- a/packages/server-utils/src/ai/openai/index.ts +++ b/packages/server-utils/src/ai/openai/index.ts @@ -225,30 +225,46 @@ function instrumentMethod( * Create a deep proxy for OpenAI client instrumentation */ function createDeepProxy(target: T, currentPath = '', options: OpenAiOptions): T { + const propertyCache = new Map(); + return new Proxy(target, { - get(obj: object, prop: string): unknown { - const value = (obj as Record)[prop]; + get(obj: object, prop: string | symbol): unknown { + const value = Reflect.get(obj, prop) as unknown; + const cachedEntry = propertyCache.get(prop); + if (cachedEntry) { + if (cachedEntry.original === value) { + return cachedEntry.wrapped; + } + propertyCache.delete(prop); + } + const methodPath = buildMethodPath(currentPath, String(prop)); const instrumentedMethod = OPENAI_METHOD_REGISTRY[methodPath as keyof typeof OPENAI_METHOD_REGISTRY]; if (typeof value === 'function' && instrumentedMethod) { - return instrumentMethod( + const wrappedMethod = instrumentMethod( value as (...args: unknown[]) => Promise, methodPath, instrumentedMethod, obj, options, ); + propertyCache.set(prop, { original: value, wrapped: wrappedMethod }); + return wrappedMethod; } if (typeof value === 'function') { // Bind non-instrumented functions to preserve the original `this` context, // which is required for accessing private class fields (e.g. #baseURL) in OpenAI SDK v5. - return value.bind(obj); + const boundMethod = value.bind(obj); + propertyCache.set(prop, { original: value, wrapped: boundMethod }); + return boundMethod; } if (value && typeof value === 'object') { - return createDeepProxy(value, methodPath, options); + const nestedProxy = createDeepProxy(value, methodPath, options); + propertyCache.set(prop, { original: value, wrapped: nestedProxy }); + return nestedProxy; } return value; diff --git a/packages/server-utils/src/ai/openai/streaming.ts b/packages/server-utils/src/ai/openai/streaming.ts index 0828e755e4cb..15686abfa072 100644 --- a/packages/server-utils/src/ai/openai/streaming.ts +++ b/packages/server-utils/src/ai/openai/streaming.ts @@ -34,10 +34,14 @@ interface StreamingState { cacheReadInputTokens: number | undefined; cacheCreationInputTokens: number | undefined; /** - * Accumulated tool calls from Chat Completion streaming, indexed by tool call index. + * Accumulated tool calls from Chat Completion streaming, keyed by string in the order they were opened. * @see https://platform.openai.com/docs/guides/function-calling?api-mode=chat#streaming */ - chatCompletionToolCalls: Record; + chatCompletionToolCalls: Map; + /** Key of the latest tool call, which unindexed argument fragments continue. */ + lastChatCompletionToolCallKey?: string; + /** Key of the call currently open for a reported `index`, when a new `id` reused that index. */ + chatCompletionToolCallKeyByIndex?: Map; /** * Accumulated function calls from Responses API streaming. * @see https://platform.openai.com/docs/guides/function-calling?api-mode=responses#streaming @@ -56,28 +60,68 @@ interface StreamingState { */ function processChatCompletionToolCalls(toolCalls: ChatCompletionToolCall[], state: StreamingState): void { for (const toolCall of toolCalls) { - const index = toolCall.index; - if (index === undefined || !toolCall.function) continue; + if (!toolCall.function) continue; + const index = getToolCallKey(toolCall, state); // Initialize tool call if this is the first chunk for this index - if (!(index in state.chatCompletionToolCalls)) { - state.chatCompletionToolCalls[index] = { + const existingToolCall = state.chatCompletionToolCalls.get(index); + if (!existingToolCall) { + state.chatCompletionToolCalls.set(index, { ...toolCall, function: { name: toolCall.function.name, arguments: toolCall.function.arguments || '', }, - }; + }); } else { // Accumulate function arguments from subsequent chunks - const existingToolCall = state.chatCompletionToolCalls[index]; - if (toolCall.function.arguments && existingToolCall?.function) { + if (toolCall.function.arguments && existingToolCall.function) { existingToolCall.function.arguments += toolCall.function.arguments; } } + state.lastChatCompletionToolCallKey = index; } } +/** + * OpenAI-compatible providers such as Gemini's OpenAI endpoint send tool calls without an `index` + * (or with `null`), or reuse the same `index` for every call. A call without a usable `index` is + * matched by a known `id`; a new `id` or a function name opens a new call, and anything else + * continues the latest call. A different non-empty `id` on an `index` that is already taken also + * opens a new call. + */ +function getToolCallKey(toolCall: ChatCompletionToolCall, state: StreamingState): string { + const calls = state.chatCompletionToolCalls; + const findById = (id: string): string | undefined => { + for (const [key, call] of calls) { + if (call.id === id) return key; + } + return undefined; + }; + + if (typeof toolCall.index === 'number') { + const keyByIndex = (state.chatCompletionToolCallKeyByIndex ??= new Map()); + const key = keyByIndex.get(toolCall.index) ?? `index-${toolCall.index}`; + const existingId = calls.get(key)?.id; + if (existingId && toolCall.id && existingId !== toolCall.id) { + const next = findById(toolCall.id) ?? `index-${toolCall.index}-${calls.size}`; + keyByIndex.set(toolCall.index, next); + return next; + } + return key; + } + if (toolCall.id) { + const known = findById(toolCall.id); + if (known !== undefined) { + return known; + } + } + if (toolCall.id || toolCall.function?.name || state.lastChatCompletionToolCallKey === undefined) { + return `no-index-${calls.size}`; + } + return state.lastChatCompletionToolCallKey; +} + /** * Processes a single OpenAI ChatCompletionChunk event, updating the streaming state. * @@ -225,7 +269,7 @@ export async function* instrumentStream( totalTokens: undefined, cacheReadInputTokens: undefined, cacheCreationInputTokens: undefined, - chatCompletionToolCalls: {}, + chatCompletionToolCalls: new Map(), responsesApiToolCalls: [], }; @@ -239,7 +283,7 @@ export async function* instrumentStream( yield event; } } finally { - const allToolCalls = [...Object.values(state.chatCompletionToolCalls), ...state.responsesApiToolCalls]; + const allToolCalls = [...state.chatCompletionToolCalls.values(), ...state.responsesApiToolCalls]; endStreamSpan(span, { ...state, toolCalls: allToolCalls }, recordOutputs); } } diff --git a/packages/server-utils/src/ai/openai/types.ts b/packages/server-utils/src/ai/openai/types.ts index cb57ea84b7a0..43bf03e51288 100644 --- a/packages/server-utils/src/ai/openai/types.ts +++ b/packages/server-utils/src/ai/openai/types.ts @@ -176,7 +176,7 @@ interface ResponseOutputItemDoneEvent { * Tool call object for Chat Completion streaming */ export interface ChatCompletionToolCall { - index?: number; // Present for streaming responses + index?: number | null; // Present for streaming responses; some compatible providers send null id: string; type?: string; // Could be missing for streaming responses function?: { diff --git a/packages/server-utils/src/ai/pi-durable/constants.ts b/packages/server-utils/src/ai/pi-durable/constants.ts index f266a250c24a..d9b5dc3ea6d7 100644 --- a/packages/server-utils/src/ai/pi-durable/constants.ts +++ b/packages/server-utils/src/ai/pi-durable/constants.ts @@ -12,6 +12,9 @@ export const PI_TASK = { /** Kind of the built-in document that holds a conversation's run control. */ export const PI_LIVE_DOC_KIND = 'pi.live'; +/** Kind of the built-in document that holds a conversation's provider session id. */ +export const PI_PROVIDER_DOC_KIND = 'pi.provider'; + /** Kind of the transcript entry that holds the tool result the model receives. */ export const PI_TOOL_RESULT_ENTRY_KIND = 'pi.tool-result'; diff --git a/packages/server-utils/src/ai/pi-durable/index.ts b/packages/server-utils/src/ai/pi-durable/index.ts index 839967be3184..b7ee007e6a02 100644 --- a/packages/server-utils/src/ai/pi-durable/index.ts +++ b/packages/server-utils/src/ai/pi-durable/index.ts @@ -8,12 +8,13 @@ import { startNewTrace, withActiveSpan, } from '@sentry/core'; +import { GEN_AI_CONVERSATION_ID } from '@sentry/conventions/attributes'; import type { GenAiOptions } from '../core/utils'; import { skipPiAiProviderIntegrations } from '../pi-ai/providers'; import { PI_DURABLE_ORIGIN, PI_TASK } from './constants'; import { instrumentPiModels } from './models'; import type { PiRuns } from './runs'; -import { createRuns, endRun, observeRunControl, startRun, toSentryConversationId } from './runs'; +import { createRuns, endRun, observeRunControl, readProviderSessionId, startRun, toSentryConversationId } from './runs'; import { instrumentTool, instrumentToolPhase } from './tools'; import type { PiHarness, @@ -137,6 +138,7 @@ function instrumentRegistry(registry: PiRegistryReader, options: PiDurableOption definition.name, observed => phase.call(receiver, taskRecord, observed, context), runtime, + context, runs, wrapTool, isAbort, @@ -191,6 +193,7 @@ function runPhase( kind: string, phase: (runtime: PiTaskRuntime) => Promise, runtime: PiTaskRuntime, + context: unknown, runs: PiRuns, wrapTool: (tool: PiTool) => PiTool, isAbort: boolean, @@ -216,7 +219,17 @@ function runPhase( } const runInScope = async (scope: Scope): Promise => { - scope.setConversationId(toSentryConversationId(runs, conversationId)); + // Read at the first phase of a run, which is a `pi.tool` phase when a restarted Harness reruns a + // tool call. + let sessionId = run?.sessionId; + if (!sessionId && (run || kind === PI_TASK.COMPACTION)) { + sessionId = await readProviderSessionId(runtime, context); + if (run && sessionId) { + run.sessionId = sessionId; + run.span.setAttribute(GEN_AI_CONVERSATION_ID, sessionId); + } + } + scope.setConversationId(sessionId ?? toSentryConversationId(runs, conversationId)); try { return await phase(observed); } catch (error) { diff --git a/packages/server-utils/src/ai/pi-durable/runs.ts b/packages/server-utils/src/ai/pi-durable/runs.ts index c0fd861f9b10..237d73d3ad39 100644 --- a/packages/server-utils/src/ai/pi-durable/runs.ts +++ b/packages/server-utils/src/ai/pi-durable/runs.ts @@ -13,11 +13,17 @@ import { import { GEN_AI_CONVERSATION_ID, GEN_AI_OPERATION_NAME, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; import { GEN_AI_INVOKE_AGENT } from '@sentry/conventions/op'; import { DEBUG_BUILD } from '../../debug-build'; -import { MAX_TRACKED_PI_RUNS, PI_DURABLE_ORIGIN, PI_LIVE_DOC_KIND } from './constants'; -import type { PiCommitChange, PiDocToken, PiLiveState, PiSettlement, PiTaskRuntime } from './types'; +import { MAX_TRACKED_PI_RUNS, PI_DURABLE_ORIGIN, PI_LIVE_DOC_KIND, PI_PROVIDER_DOC_KIND } from './constants'; +import type { PiCommitChange, PiDocToken, PiLiveState, PiProviderState, PiSettlement, PiTaskRuntime } from './types'; import { withCleanScopes } from '../../utils/withCleanScopes'; import { bound } from './utils'; +// The definition of `pi.provider` as pi-durable checks it on a read: the address, the version and the +// persisted semantics of the document. +const PROVIDER_DOC = { + definition: { kind: PI_PROVIDER_DOC_KIND, version: 1, scope: 'conversation', history: 'latest', fork: 'initial' }, +}; + // Unanswered reasons that mean the run was stopped, not that it failed. const CANCELLED_REASONS = new Set(['aborted', 'reset']); @@ -32,6 +38,8 @@ export interface PiRun { isolationScope: Scope; /** The run's first input submission, which identifies the run in `pi.live`. */ firstInput?: unknown; + /** The conversation's provider session id, read by the first phase of the run. */ + sessionId?: string; /** * The status the run ends with, set while the commit that ends it is in flight. `harness.close()` * can begin in that gap, because `wait()` resolves as soon as the commit settles the inputs. @@ -42,9 +50,9 @@ export interface PiRun { /** Open runs of one Harness, keyed by the pi-durable conversation id. */ export interface PiRuns { /** - * Random id of this Harness, prefixed to `gen_ai.conversation.id`. pi-durable numbers - * conversations per storage from 1, so the bare id would merge the root conversations of every - * Harness into one Sentry conversation. + * Random id of this Harness, prefixed to `gen_ai.conversation.id` until the provider session id of + * the conversation is known. pi-durable numbers conversations per storage from 1, so the bare id + * would merge the root conversations of every Harness into one Sentry conversation. */ harnessId: string; active: Map; @@ -96,7 +104,27 @@ export function createRuns(): PiRuns { } export function toSentryConversationId(runs: PiRuns, conversationId: unknown): string { - return `${runs.harnessId}:${String(conversationId)}`; + return runs.active.get(conversationId)?.sessionId ?? `${runs.harnessId}:${String(conversationId)}`; +} + +/** + * Read the provider session id of the conversation, or `undefined` when pi-durable keeps none. + * + * pi-durable 1.0.2 and later keep a UUIDv7 per conversation in the `pi.provider` document, stored with + * the conversation. Unlike the harness prefix, it stays the same when a Harness opens the storage + * again, for example after a Durable Object restarts, and it is unique across storages. + */ +export async function readProviderSessionId(runtime: PiTaskRuntime, context: unknown): Promise { + try { + const provider = (await runtime.snapshot?.(PROVIDER_DOC, runtime.conversationId, context)) as + | PiProviderState + | undefined; + return typeof provider?.sessionId === 'string' && provider.sessionId ? provider.sessionId : undefined; + } catch (error) { + // A later version of the document fails the read. The harness prefix is used then. + DEBUG_BUILD && debug.warn('[pi-durable] failed to read the provider session id', error); + return undefined; + } } /** Start a run of `conversationId`: a new trace, or a child of the tool call that owns the conversation. */ diff --git a/packages/server-utils/src/ai/pi-durable/types.ts b/packages/server-utils/src/ai/pi-durable/types.ts index 7c87317eb26a..c4ec68ceef8c 100644 --- a/packages/server-utils/src/ai/pi-durable/types.ts +++ b/packages/server-utils/src/ai/pi-durable/types.ts @@ -99,6 +99,11 @@ export interface PiLiveState { run?: { inputs?: unknown[] }; } +/** The `pi.provider` document, in pi-durable 1.0.2 and later. */ +export interface PiProviderState { + sessionId?: string; +} + export type PiCommitChange = (tx: object, current: unknown) => unknown; /** pi-durable ids (conversations, tasks, submissions) are numbers, typed as `unknown` here. */ @@ -108,6 +113,7 @@ export interface PiTaskRuntime { readonly signal?: AbortSignal; agent(context: unknown): Promise; commit(change: PiCommitChange, context: unknown): Promise; + snapshot?(token: PiDocToken, ownerId: unknown, ...rest: unknown[]): Promise; } export type PiPhaseHandler = (task: unknown, runtime: PiTaskRuntime, context: unknown) => Promise; diff --git a/packages/server-utils/src/ai/workers-ai/utils.ts b/packages/server-utils/src/ai/workers-ai/utils.ts index 29db4d2cd136..422da29faaaf 100644 --- a/packages/server-utils/src/ai/workers-ai/utils.ts +++ b/packages/server-utils/src/ai/workers-ai/utils.ts @@ -3,6 +3,7 @@ import { GEN_AI_EMBEDDINGS_INPUT, GEN_AI_INPUT_MESSAGES, GEN_AI_OPERATION_NAME, + GEN_AI_OUTPUT_MESSAGES, GEN_AI_PROVIDER_NAME, GEN_AI_REQUEST_FREQUENCY_PENALTY, GEN_AI_REQUEST_MAX_TOKENS, @@ -11,12 +12,13 @@ import { GEN_AI_REQUEST_TEMPERATURE, GEN_AI_REQUEST_TOP_K, GEN_AI_REQUEST_TOP_P, + GEN_AI_RESPONSE_MODEL, GEN_AI_RESPONSE_TEXT, GEN_AI_RESPONSE_TOOL_CALLS, GEN_AI_SYSTEM_INSTRUCTIONS, SENTRY_ORIGIN, } from '@sentry/conventions/attributes'; -import { GEN_AI_CHAT, GEN_AI_EMBEDDINGS, GEN_AI_DECIDE } from '@sentry/conventions/op'; +import { GEN_AI_CHAT, GEN_AI_EMBEDDINGS, GEN_AI_DECIDE, GEN_AI_EXTRACT } from '@sentry/conventions/op'; import { isObjectLike, stringify } from '@sentry/core'; import type { Span, SpanAttributeValue } from '@sentry/core'; import { GEN_AI_REQUEST_STREAM_ATTRIBUTE } from '../core/gen-ai-attributes'; @@ -32,12 +34,13 @@ export { setOutputMessagesAttribute }; import { WORKERS_AI_ORIGIN, WORKERS_AI_PROVIDER_NAME } from './constants'; import type { WorkersAiInput, WorkersAiOutput } from './types'; -export type WorkersAiOperationName = 'chat' | 'embeddings' | 'decide'; +export type WorkersAiOperationName = 'chat' | 'embeddings' | 'decide' | 'extract'; export const WORKERS_AI_OPERATION_SPAN_OPS: Record = { chat: GEN_AI_CHAT, embeddings: GEN_AI_EMBEDDINGS, decide: GEN_AI_DECIDE, + extract: GEN_AI_EXTRACT, }; /** @@ -45,6 +48,13 @@ export const WORKERS_AI_OPERATION_SPAN_OPS: Record model.startsWith(prefix))) { return 'decide'; } + if (typeof model === 'string' && EXTRACT_MODEL_PREFIXES.some(prefix => model.startsWith(prefix))) { + return 'extract'; + } if (inputs && typeof inputs === 'object') { if ('messages' in inputs || 'prompt' in inputs) { return 'chat'; @@ -123,6 +136,16 @@ export function addRequestAttributes(span: Span, inputs: unknown, operationName: return; } + if (operationName === 'extract') { + // Record the request body as passed, but never the image data. + const { model: _model, ...request } = inputs as Record; + if (Array.isArray(request.images)) { + request.images = request.images.map(() => BLOB_SUBSTITUTE); + } + span.setAttribute(GEN_AI_INPUT_MESSAGES, stringify([request])); + return; + } + // Store embeddings input on a separate attribute if (operationName === 'embeddings') { const text = params.text; @@ -166,6 +189,11 @@ export function addResponseAttributes( return; } + if (operationName === 'extract') { + addExtractResponseAttributes(span, result, recordOutputs); + return; + } + if ( !result || typeof result !== 'object' || @@ -200,3 +228,28 @@ export function addResponseAttributes( setOutputMessagesAttribute(span, { responseText, toolCalls }); } } + +function addExtractResponseAttributes(span: Span, body: unknown, recordOutputs: boolean): void { + if (!isObjectLike(body)) { + return; + } + + const { model, usage, ...response } = body; + + if (typeof model === 'string') { + span.setAttribute(GEN_AI_RESPONSE_MODEL, model); + } + + if (isObjectLike(usage)) { + span.setAttributes( + getTokenUsageAttributes( + typeof usage.input_tokens === 'number' ? usage.input_tokens : undefined, + typeof usage.output_tokens === 'number' ? usage.output_tokens : undefined, + ), + ); + } + + if (recordOutputs) { + span.setAttribute(GEN_AI_OUTPUT_MESSAGES, stringify([response])); + } +} diff --git a/packages/server-utils/src/exports.ts b/packages/server-utils/src/exports.ts index 15381f95dc9b..799ed7c69d18 100644 --- a/packages/server-utils/src/exports.ts +++ b/packages/server-utils/src/exports.ts @@ -1,5 +1,7 @@ // Shared exports not using diagnostics channels export { setHttpServerSpanRouteAttribute } from './utils/setHttpServerSpanRouteAttribute'; +export { wrapServerRequestHandler } from './utils/wrapServerRequestHandler'; +export type { ServerRequestHandlerConfig } from './utils/wrapServerRequestHandler'; export { setAsyncLocalStorageAsyncContextStrategy } from './async-context'; export { openTelemetryIntegration, getOtlpTracesEndpoint } from './opentelemetry'; export * from './ai'; diff --git a/packages/server-utils/src/integrations/amqplib.ts b/packages/server-utils/src/integrations/amqplib.ts index ec1d29db3b66..f5db922608a8 100644 --- a/packages/server-utils/src/integrations/amqplib.ts +++ b/packages/server-utils/src/integrations/amqplib.ts @@ -38,8 +38,8 @@ import { bindTracingChannelToSpan } from '../tracing-channel'; // When enabled, the OTel 'Amqplib' integration is omitted from the default set. const INTEGRATION_NAME = 'Amqplib' as const; -const PUBLISHER_ORIGIN = 'auto.amqplib.publisher'; -const CONSUMER_ORIGIN = 'auto.amqplib.consumer'; +const PUBLISHER_ORIGIN = 'auto.queue.amqplib.publisher'; +const CONSUMER_ORIGIN = 'auto.queue.amqplib.consumer'; const ATTR_MESSAGING_RABBITMQ_DESTINATION_ROUTING_KEY = 'messaging.rabbitmq.destination.routing_key'; const ATTR_MESSAGING_CONVERSATION_ID = 'messaging.message.conversation_id'; diff --git a/packages/server-utils/src/integrations/anthropic.ts b/packages/server-utils/src/integrations/anthropic.ts index f2905c397abf..044b2d09ff43 100644 --- a/packages/server-utils/src/integrations/anthropic.ts +++ b/packages/server-utils/src/integrations/anthropic.ts @@ -14,7 +14,7 @@ import { wrapApiPromiseResponse } from '../ai/core/apiPromise'; import { addPrivateRequestAttributes, addResponseAttributes, extractRequestAttributes } from '../ai/anthropic-ai'; import { ANTHROPIC_AI_ORIGIN } from '../ai/anthropic-ai/constants'; import { instrumentAsyncIterableStream, instrumentMessageStream } from '../ai/anthropic-ai/streaming'; -import { instrumentToolRunner } from '../ai/anthropic-ai/tool-runner'; +import { instrumentSessionToolRunner, instrumentToolRunner } from '../ai/anthropic-ai/tool-runner'; import type { AnthropicAiOptions, AnthropicAiResponse } from '../ai/anthropic-ai/types'; import type { OrchestrionChannelContext } from '../orchestrion/types'; import { CHANNELS } from '../orchestrion/channels'; @@ -80,6 +80,11 @@ function instrumentAnthropic(options: AnthropicAiOptions): void { .end.subscribe(message => { instrumentToolRunner((message as OrchestrionChannelContext).result, options); }); + diagnosticsChannel + .tracingChannel(CHANNELS.ANTHROPIC_SESSION_TOOL_RUNNER) + .end.subscribe(message => { + instrumentSessionToolRunner((message as OrchestrionChannelContext).self, options); + }); } /** diff --git a/packages/server-utils/src/integrations/aws-sdk/constants.ts b/packages/server-utils/src/integrations/aws-sdk/constants.ts index ae27a6e50315..abc16f1b9bd1 100644 --- a/packages/server-utils/src/integrations/aws-sdk/constants.ts +++ b/packages/server-utils/src/integrations/aws-sdk/constants.ts @@ -4,8 +4,8 @@ * directly). These are either Sentry-specific (the span origin) or attribute *values* (not keys). */ -/** The span origin every aws-sdk channel span carries. */ -export const AWS_SDK_ORIGIN = 'auto.aws.aws_sdk'; +/** The integration name part of every aws-sdk channel span origin (`auto..aws_sdk`). */ +export const AWS_SDK_ORIGIN_INTEGRATION_NAME = 'aws_sdk'; /** DynamoDB `db.system.name` value (an attribute value, not a key, so not covered by conventions). */ export const DB_SYSTEM_VALUE_DYNAMODB = 'dynamodb'; diff --git a/packages/server-utils/src/integrations/aws-sdk/index.ts b/packages/server-utils/src/integrations/aws-sdk/index.ts index 95b38b41eece..10951179dd04 100644 --- a/packages/server-utils/src/integrations/aws-sdk/index.ts +++ b/packages/server-utils/src/integrations/aws-sdk/index.ts @@ -16,7 +16,7 @@ import { awsSdkModuleNames } from '../../orchestrion/config/aws-sdk'; import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; import type { TracingChannelLifeCycleOptions } from '../../tracing-channel'; import { bindTracingChannelToSpan, safeChannelCallback } from '../../tracing-channel'; -import { AWS_SDK_ORIGIN } from './constants'; +import { AWS_SDK_ORIGIN_INTEGRATION_NAME } from './constants'; import { ServicesExtensions } from './services'; import type { NormalizedRequest, NormalizedResponse, RequestMetadata } from './types'; import { extractAttributesFromNormalizedRequest, normalizeV3Request, removeSuffixFromStringIfExists } from './utils'; @@ -74,6 +74,13 @@ const _awsIntegration = (() => { }; }) satisfies IntegrationFn; +function getSpanOrigin(op: string): string { + const opCategory = op.split('.')[0]; + // AI integrations use `ai` as their origin category, although their ops are `gen_ai.*`. + const category = opCategory === 'gen_ai' ? 'ai' : opCategory; + return `auto.${category}.${AWS_SDK_ORIGIN_INTEGRATION_NAME}`; +} + function instrumentAwsSdk(servicesExtensions: ServicesExtensions): void { const getSpan = (data: AwsSendChannelContext): Span | undefined => safeChannelCallback(() => { @@ -103,14 +110,15 @@ function instrumentAwsSdk(servicesExtensions: ServicesExtensions): void { const normalizedRequest = normalizeV3Request(serviceName, commandName, command.input, undefined); const requestMetadata = servicesExtensions.requestPreSpanHook(normalizedRequest); + // `rpc` matches what the exporter infers from `rpc.service` for the OTel aws-sdk spans; + // service extensions override it where inference yields a different op (DynamoDB: `db`). + const op = requestMetadata.spanOp || RPC; const span = startInactiveSpan({ name: requestMetadata.spanName ?? `${normalizedRequest.serviceName}.${normalizedRequest.commandName}`, - // `rpc` matches what the exporter infers from `rpc.service` for the OTel aws-sdk spans; - // service extensions override it where inference yields a different op (DynamoDB: `db`). - op: requestMetadata.spanOp || RPC, + op, attributes: { [SENTRY_KIND]: 'client', - [SENTRY_ORIGIN]: AWS_SDK_ORIGIN, + [SENTRY_ORIGIN]: getSpanOrigin(op), ...extractAttributesFromNormalizedRequest(normalizedRequest), ...requestMetadata.spanAttributes, }, diff --git a/packages/server-utils/src/integrations/firebase/firestore.ts b/packages/server-utils/src/integrations/firebase/firestore.ts index 43928bd504b0..155e07797d1a 100644 --- a/packages/server-utils/src/integrations/firebase/firestore.ts +++ b/packages/server-utils/src/integrations/firebase/firestore.ts @@ -25,7 +25,7 @@ export function startFirestoreSpan(spanName: string, reference: FirestoreReferen attributes: { [SENTRY_OP]: DB_QUERY, [SENTRY_KIND]: 'client', - [SENTRY_ORIGIN]: 'auto.firebase.firestore', + [SENTRY_ORIGIN]: 'auto.db.firebase.firestore', [DB_OPERATION_NAME]: spanName, ...buildAttributes(reference), }, diff --git a/packages/server-utils/src/integrations/firebase/functions.ts b/packages/server-utils/src/integrations/firebase/functions.ts index 1ad3d8c03f90..7378e94c3738 100644 --- a/packages/server-utils/src/integrations/firebase/functions.ts +++ b/packages/server-utils/src/integrations/firebase/functions.ts @@ -20,7 +20,7 @@ import { startSpanManual, } from '@sentry/core'; -const FUNCTIONS_ORIGIN = 'auto.firebase.functions'; +const FUNCTIONS_ORIGIN = 'auto.function.firebase'; // Set on a wrapped handler so re-entrant `start` events don't double-wrap it. const WRAPPED = '__sentryFirebaseWrapped'; diff --git a/packages/server-utils/src/integrations/hapi/hapi-error-handler.ts b/packages/server-utils/src/integrations/hapi/hapi-error-handler.ts index d79b35b4502a..b6dbf152fc46 100644 --- a/packages/server-utils/src/integrations/hapi/hapi-error-handler.ts +++ b/packages/server-utils/src/integrations/hapi/hapi-error-handler.ts @@ -73,7 +73,7 @@ export function attachHapiErrorHandler(server: HapiServer, shouldHandleError?: H if (getIsolationScope() !== getDefaultIsolationScope()) { const route = request.route; if (route?.path) { - getIsolationScope().setTransactionName(`${route.method.toUpperCase()} ${route.path}`); + getIsolationScope().setTransactionName(`${request.method.toUpperCase()} ${route.path}`); } } else { DEBUG_BUILD && diff --git a/packages/server-utils/src/integrations/hapi/hapi-types.ts b/packages/server-utils/src/integrations/hapi/hapi-types.ts index 0afb23cc1448..d1ee5f3739b8 100644 --- a/packages/server-utils/src/integrations/hapi/hapi-types.ts +++ b/packages/server-utils/src/integrations/hapi/hapi-types.ts @@ -29,7 +29,7 @@ export interface ServerRouteOptions { export interface ServerRoute { path: string; - method: string; + method: string | string[]; handler?: LifecycleMethod | unknown; options?: ((server: unknown) => ServerRouteOptions) | ServerRouteOptions; [key: string]: unknown; @@ -96,6 +96,7 @@ export interface HapiResponse { /** The subset of a hapi request the error listener reads. */ export interface HapiRequest { + method: string; route: { path?: string; method: string }; response?: HapiResponse; [key: string]: unknown; @@ -122,9 +123,3 @@ export interface HapiServer { events: HapiServerEvents; [key: string]: unknown; } - -export enum AttributeNames { - HAPI_TYPE = 'hapi.type', - PLUGIN_NAME = 'hapi.plugin.name', - EXT_TYPE = 'server.ext.type', -} diff --git a/packages/server-utils/src/integrations/hapi/hapi-utils.ts b/packages/server-utils/src/integrations/hapi/hapi-utils.ts index 5c7afa11a268..e5d8f7cd8279 100644 --- a/packages/server-utils/src/integrations/hapi/hapi-utils.ts +++ b/packages/server-utils/src/integrations/hapi/hapi-utils.ts @@ -18,7 +18,16 @@ import { ROUTER_SPAN_NAME_FALLBACK, startSpan, } from '@sentry/core'; -import { SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; +import { + HAPI_EXT_TYPE, + HAPI_PLUGIN_NAME, + HAPI_TYPE, + HTTP_REQUEST_METHOD, + HTTP_ROUTE, + SENTRY_OP, + SENTRY_ORIGIN, + SERVER_EXT_TYPE, +} from '@sentry/conventions/attributes'; import { HANDLER, MIDDLEWARE, ROUTER } from '@sentry/conventions/op'; import type { HapiRequest, @@ -29,11 +38,9 @@ import type { ServerExtEventsObject, ServerExtEventsRequestObject, ServerRequestExtType, - ServerRoute, ServerRouteOptions, } from './hapi-types'; -import { HTTP_REQUEST_METHOD, HTTP_ROUTE } from '@sentry/conventions/attributes'; -import { AttributeNames, handlerPatched, HapiLayerType, HapiLifecycleMethodNames } from './hapi-types'; +import { handlerPatched, HapiLayerType, HapiLifecycleMethodNames } from './hapi-types'; import { setHttpServerSpanRouteAttribute } from '../../utils/setHttpServerSpanRouteAttribute'; /** @@ -108,17 +115,25 @@ const isPatchableExtMethod = ( }; /** Build the span name and attributes for a Hapi route. */ -export const getRouteMetadata = (route: ServerRoute, pluginName?: string): SpanMetadata => { +export const getRouteMetadata = ({ + path, + method, + pluginName, +}: { + path: string; + method?: string; + pluginName?: string; +}): SpanMetadata => { const attributes: SpanAttributes = { - [HTTP_ROUTE]: route.path, - [HTTP_REQUEST_METHOD]: route.method, + [HTTP_ROUTE]: path, + [HTTP_REQUEST_METHOD]: method, }; if (pluginName) { - attributes[AttributeNames.HAPI_TYPE] = HapiLayerType.PLUGIN; - attributes[AttributeNames.PLUGIN_NAME] = pluginName; + attributes[HAPI_TYPE] = HapiLayerType.PLUGIN; + attributes[HAPI_PLUGIN_NAME] = pluginName; } else { - attributes[AttributeNames.HAPI_TYPE] = HapiLayerType.ROUTER; + attributes[HAPI_TYPE] = HapiLayerType.ROUTER; } const client = getClient(); @@ -126,10 +141,11 @@ export const getRouteMetadata = (route: ServerRoute, pluginName?: string): SpanM // handler spans are named after their route alone, without the method prefix. const isStreamedSpan = !!client && hasSpanStreamingEnabled(client); const fallbackName = pluginName ? REQUEST_HANDLER_SPAN_NAME_FALLBACK : ROUTER_SPAN_NAME_FALLBACK; + const staticName = method ? `${method} ${path}` : path; return { attributes, - name: isStreamedSpan ? route.path || fallbackName : `${route.method.toUpperCase()} ${route.path}`, + name: isStreamedSpan ? path || fallbackName : staticName, }; }; @@ -144,23 +160,20 @@ export const getExtMetadata = ( // `method` is the default name for the extension in the ServerExtEventsObject format. baseName = `ext - ${extPoint} - ${methodName}`; } + const attributes: SpanAttributes = { + [HAPI_EXT_TYPE]: extPoint, + // TODO(v12): Remove `server.ext.type`, which `hapi.ext.type` replaces. + // oxlint-disable-next-line typescript/no-deprecated + [SERVER_EXT_TYPE]: extPoint, + [HAPI_TYPE]: HapiLayerType.EXT, + }; if (pluginName) { return { - attributes: { - [AttributeNames.EXT_TYPE]: extPoint, - [AttributeNames.HAPI_TYPE]: HapiLayerType.EXT, - [AttributeNames.PLUGIN_NAME]: pluginName, - }, + attributes: { ...attributes, [HAPI_PLUGIN_NAME]: pluginName }, name: `${pluginName}: ${baseName}`, }; } - return { - attributes: { - [AttributeNames.EXT_TYPE]: extPoint, - [AttributeNames.HAPI_TYPE]: HapiLayerType.EXT, - }, - name: baseName, - }; + return { attributes, name: baseName }; }; const HAPI_TYPE_TO_SPAN_OP: Record = { @@ -170,7 +183,7 @@ const HAPI_TYPE_TO_SPAN_OP: Record = { }; function startMetadataSpan(metadata: SpanMetadata, original: () => unknown): unknown { - const hapiType = metadata.attributes[AttributeNames.HAPI_TYPE]; + const hapiType = metadata.attributes[HAPI_TYPE]; const op = hapiType ? HAPI_TYPE_TO_SPAN_OP[hapiType] : undefined; return startSpan( { @@ -185,6 +198,12 @@ function startMetadataSpan(metadata: SpanMetadata, original: () => unknown): unk ); } +// The route config can register several methods (or `'*'`), so read the method +// Hapi resolved for this request instead. +function getRequestMethod(request: unknown): string | undefined { + return isObjectLike(request) && typeof request.method === 'string' ? request.method.toUpperCase() : undefined; +} + /** * Patches each individual route handler method in order to create the span. It * does not create spans when there is no parent span. @@ -199,7 +218,7 @@ function wrapRouteHandler(route: PatchableServerRoute, pluginName?: string): Pat return oldHandler.call(this, ...params); } setHttpServerSpanRouteAttribute(route.path); - const metadata = getRouteMetadata(route, pluginName); + const metadata = getRouteMetadata({ path: route.path, method: getRequestMethod(params[0]), pluginName }); return startMetadataSpan(metadata, () => oldHandler.call(this, ...params)); }; }; diff --git a/packages/server-utils/src/integrations/hono/honoIntegration.ts b/packages/server-utils/src/integrations/hono/honoIntegration.ts index b66fe6b50de4..575462d6d018 100644 --- a/packages/server-utils/src/integrations/hono/honoIntegration.ts +++ b/packages/server-utils/src/integrations/hono/honoIntegration.ts @@ -4,7 +4,7 @@ import { HTTP_SERVER } from '@sentry/conventions/op'; import type { IntegrationFn } from '@sentry/core'; import { defineIntegration, startInactiveSpan } from '@sentry/core'; import type { HonoRuntime } from './connInfo'; -import type { Env, Hono, MiddlewareHandler } from './honoTypes'; +import type { Env, Hono, MatchedHandler, MiddlewareHandler } from './honoTypes'; import type { OrchestrionChannelContext } from '../../orchestrion/types'; import { CHANNELS } from '../../orchestrion/channels'; import { honoModuleNames } from '../../orchestrion/config/hono'; @@ -13,7 +13,8 @@ import { bindTracingChannelToSpan, safeChannelCallback } from '../../tracing-cha import { applyPatches } from './applyPatches'; import { INTERNAL_REQUEST_ORIGIN } from './constants'; import { createHonoRequestMiddleware } from './createHonoMiddleware'; -import { isMiddleware } from './isMiddleware'; +import { getMiddlewareFlags } from './isMiddleware'; +import { isMatchedHandlerList } from './matchResult'; import { extractPathname, isInternalRequestSpanActive } from './patchAppRequest'; import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan'; import type { SentryHonoMiddlewareOptions } from './types'; @@ -49,10 +50,14 @@ export function honoMiddleware(app: Hono, options: HonoIntegra }); } -// A Hono `matchResult[0]` entry: `[[handler, routeMeta], paramIndexMap]` — `compose` runs the -// handler (`entry[0][0]`) and the `matchedRoutes` getter reads `routeMeta` (`entry[0][1]`). -// oxlint-disable-next-line typescript/no-explicit-any -type MatchedHandlerEntry = [[any, any], any]; +function wrapMatchedMiddleware(handlers: MatchedHandler[]): void { + const middlewareFlags = getMiddlewareFlags(handlers.map(([[, route]]) => route)); + for (const [i, [pair]] of handlers.entries()) { + if (middlewareFlags[i]) { + pair[0] = wrapMiddlewareWithSpan(pair[0]); + } + } +} /** * Per-request Context hook. `#dispatch` builds `new Context(req, { matchResult })` before its @@ -69,47 +74,19 @@ function injectHonoInstrumentation( requestMiddleware: MiddlewareHandler, injectedHandlerLists: WeakSet, ): void { - const ctorOptions = message.arguments?.[1] as { matchResult?: [MatchedHandlerEntry[], unknown] } | undefined; + const ctorOptions = message.arguments?.[1] as { matchResult?: [unknown[], unknown] } | undefined; const handlers = ctorOptions?.matchResult?.[0]; if (!Array.isArray(handlers) || injectedHandlerLists.has(handlers)) { return; } injectedHandlerLists.add(handlers); - // Classify matched handlers with the same positional heuristic as `wrapSubAppMiddleware`: within a - // method+path group the last handler is the route handler and earlier ones are middleware; `.use()` - // registers as method 'ALL' with a lone genuine-middleware entry, so fall back to arity there. - // Position is needed because a route handler declared with an unused `next` param has middleware arity. - const lastIndexByKey = new Map(); - for (const [i, entry] of handlers.entries()) { - const routeMeta = entry?.[0]?.[1] as { method?: string; path?: string } | undefined; - if (routeMeta?.method != null && routeMeta.path != null) { - // \0 is a collision-free delimiter: it cannot appear in a valid HTTP method or URL path. - lastIndexByKey.set(`${routeMeta.method}\0${routeMeta.path}`, i); - } - } - - for (const [i, entry] of handlers.entries()) { - const pair = entry?.[0]; - const handler = pair?.[0]; - if (typeof handler !== 'function') { - continue; - } - - const routeMeta = pair?.[1] as { method?: string; path?: string } | undefined; - const isMW = - routeMeta?.method != null && routeMeta.path != null - ? lastIndexByKey.get(`${routeMeta.method}\0${routeMeta.path}`) !== i || - (routeMeta.method === 'ALL' && isMiddleware(handler)) - : isMiddleware(handler); - - if (isMW) { - pair[0] = wrapMiddlewareWithSpan(handler as MiddlewareHandler); - } + if (isMatchedHandlerList(handlers)) { + wrapMatchedMiddleware(handlers); } - // Prepend the Sentry request/response middleware. `routeMeta` is what the `matchedRoutes` getter - // exposes; its middleware arity keeps route-name resolution from picking it. + // Prepend the Sentry request/response middleware. Its arity marks it as a middleware, so its `/*` + // never becomes the route name. const routeMeta = { basePath: '/', path: '/*', method: 'ALL', handler: requestMiddleware }; handlers.unshift([[requestMiddleware, routeMeta], {}]); } diff --git a/packages/server-utils/src/integrations/hono/honoTypes.ts b/packages/server-utils/src/integrations/hono/honoTypes.ts index eadbe688e42a..ce8661a073f8 100644 --- a/packages/server-utils/src/integrations/hono/honoTypes.ts +++ b/packages/server-utils/src/integrations/hono/honoTypes.ts @@ -29,15 +29,16 @@ export interface HonoRoute { handler: (...args: any[]) => any; } +// One entry of Hono's `matchResult[0]`. +export type MatchedHandler = [pair: [handler: HonoRoute['handler'], route: HonoRoute], paramIndexMap: unknown]; + export interface HonoRequest { raw: Request; method: string; path: string; routeIndex: number; - // These are public (though deprecated) getters on Hono's request. Reading them avoids a runtime - // import of the `hono/route` helpers, which are their non-deprecated replacements. - matchedRoutes: HonoRoute[]; - routePath: string; + // Deprecated getter that Hono 5 removes. + matchedRoutes?: HonoRoute[]; [key: string]: any; } diff --git a/packages/server-utils/src/integrations/hono/isMiddleware.ts b/packages/server-utils/src/integrations/hono/isMiddleware.ts index 9c18e43e8602..ee7ad8d08afa 100644 --- a/packages/server-utils/src/integrations/hono/isMiddleware.ts +++ b/packages/server-utils/src/integrations/hono/isMiddleware.ts @@ -1,3 +1,5 @@ +import type { HonoRoute } from './honoTypes'; + // Hono stores the original (unwrapped) handler under this key when it wraps a sub-app's handlers // for a custom `onError`. Unwrapping it lets us inspect the real handler instead of the wrapper. // See https://github.com/honojs/hono/blob/9f0dadf141a3242a6c3b77462c7d33c6ce0f599d/src/hono-base.ts#L224-L226 @@ -12,7 +14,7 @@ const COMPOSED_HANDLER = '__COMPOSED_HANDLER'; * https://github.com/honojs/hono/blob/97c6fe1f12298c715eb7b2da65b4b6e0d81682bb/src/utils/handler.ts#L8 * * This is only one signal — callers that must classify inline middleware sharing a method+path with - * their handler (e.g. `wrapSubAppMiddleware`) additionally need positional information. + * their handler additionally need positional information (`getMiddlewareFlags`). */ export function isMiddleware(handler: unknown): boolean { if (typeof handler !== 'function') { @@ -24,3 +26,19 @@ export function isMiddleware(handler: unknown): boolean { return (original as (...args: unknown[]) => unknown).length >= 2; } + +/** + * Tells for each route if it is a middleware. Hono has no flag for this. In a method+path group, the + * last handler is the route handler (`app.get('/path', mw, handler)`). `.use()` adds middleware with + * method `ALL` and no route handler, so for `ALL` routes the arity also counts. Arity alone is not + * enough, because a route handler can declare an unused `next`. + */ +export function getMiddlewareFlags(routes: HonoRoute[]): boolean[] { + // `\0` cannot appear in an HTTP method or a URL path. + const groupKey = (route: HonoRoute): string => `${route.method}\0${route.path}`; + const lastIndexByKey = new Map(routes.map((route, i) => [groupKey(route), i])); + + return routes.map( + (route, i) => lastIndexByKey.get(groupKey(route)) !== i || (route.method === 'ALL' && isMiddleware(route.handler)), + ); +} diff --git a/packages/server-utils/src/integrations/hono/matchResult.ts b/packages/server-utils/src/integrations/hono/matchResult.ts new file mode 100644 index 000000000000..e6bd298f1206 --- /dev/null +++ b/packages/server-utils/src/integrations/hono/matchResult.ts @@ -0,0 +1,26 @@ +import { isPlainObject } from '@sentry/core'; +import type { MatchedHandler } from './honoTypes'; + +function isMatchedHandler(entry: unknown): entry is MatchedHandler { + const pair: unknown = Array.isArray(entry) ? entry[0] : undefined; + if (!Array.isArray(pair)) { + return false; + } + + const [handler, route]: unknown[] = pair; + return ( + typeof handler === 'function' && + isPlainObject(route) && + typeof route.method === 'string' && + typeof route.path === 'string' && + typeof route.handler === 'function' + ); +} + +/** + * Checks the entry shape that `compose` and `hono/route` read. The match result is a Hono internal, so + * the SDK leaves other shapes alone. + */ +export function isMatchedHandlerList(handlers: unknown): handlers is MatchedHandler[] { + return Array.isArray(handlers) && handlers.every(isMatchedHandler); +} diff --git a/packages/server-utils/src/integrations/hono/patchRoute.ts b/packages/server-utils/src/integrations/hono/patchRoute.ts index 4f13b1b760ad..398df72fb924 100644 --- a/packages/server-utils/src/integrations/hono/patchRoute.ts +++ b/packages/server-utils/src/integrations/hono/patchRoute.ts @@ -2,7 +2,7 @@ import { debug, getOriginalFunction } from '@sentry/core'; import type { WrappedFunction } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import type { Hono, HonoRoute } from './honoTypes'; -import { isMiddleware } from './isMiddleware'; +import { getMiddlewareFlags } from './isMiddleware'; import { patchAppRequest } from './patchAppRequest'; import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan'; @@ -103,31 +103,12 @@ export function installRouteHookOnPrototype(honoBaseProto: HonoBaseProto): Route } /** - * Identifies middleware handlers in a sub-app's flat routes array and wraps them in spans. - * - * Heuristics (since Hono has no "isMiddleware" flag): - * 1. Position: `app.get('/path', mw, handler)` produces entries with the same method+path. - * All but the LAST are middleware (they call `next()`). - * 2. Arity (# of params) for method 'ALL': `.use()` handlers always have 2+ params (context, next), - * while `.all()` route handlers typically have 1 (`context` only). - * See: https://github.com/honojs/hono/blob/18fe604c8cefc2628240651b1af219692e1918c1/src/hono-base.ts#L156-L168 + * Wraps the middleware handlers of a sub-app's routes in spans. */ export function wrapSubAppMiddleware(routes: HonoRoute[]): void { - const lastIndexByKey = new Map(); - for (const [i, route] of routes.entries()) { - // \0 (null byte) is a collision-free delimiter: it cannot appear in a valid HTTP method name or URL path - lastIndexByKey.set(`${route.method}\0${route.path}`, i); - } - + const middlewareFlags = getMiddlewareFlags(routes); for (const [i, route] of routes.entries()) { - if (typeof route.handler !== 'function') { - continue; - } - - const isLastForGroup = lastIndexByKey.get(`${route.method}\0${route.path}`) === i; - - const isMW = !isLastForGroup || (route.method === 'ALL' && isMiddleware(route.handler)); - if (isMW) { + if (middlewareFlags[i] && typeof route.handler === 'function') { route.handler = wrapMiddlewareWithSpan(route.handler); } } diff --git a/packages/server-utils/src/integrations/kafkajs/spans.ts b/packages/server-utils/src/integrations/kafkajs/spans.ts index 3ed0ca5a84bd..acde03f8009a 100644 --- a/packages/server-utils/src/integrations/kafkajs/spans.ts +++ b/packages/server-utils/src/integrations/kafkajs/spans.ts @@ -43,8 +43,8 @@ import { MESSAGING_SYSTEM_VALUE_KAFKA, } from './semconv'; -const PRODUCER_ORIGIN = 'auto.kafkajs.producer'; -const CONSUMER_ORIGIN = 'auto.kafkajs.consumer'; +const PRODUCER_ORIGIN = 'auto.queue.kafkajs.producer'; +const CONSUMER_ORIGIN = 'auto.queue.kafkajs.consumer'; // `@opentelemetry/api` `TraceFlags`, inlined to avoid an OTel dep: SAMPLED = 0x1, NONE = 0x0. const TRACE_FLAG_SAMPLED = 1; diff --git a/packages/server-utils/src/integrations/vercel-ai/index.ts b/packages/server-utils/src/integrations/vercel-ai/index.ts index 6cf24b1e70b3..7d847a76dfe8 100644 --- a/packages/server-utils/src/integrations/vercel-ai/index.ts +++ b/packages/server-utils/src/integrations/vercel-ai/index.ts @@ -1,6 +1,7 @@ import { defineIntegration, waitForTracingChannelBinding, type IntegrationFn } from '@sentry/core'; import type { GenAiOptions } from '../../ai/core/utils'; import { subscribeVercelAiTracingChannel } from './vercel-ai-dc-subscriber'; +import { registerVercelAiToolTelemetry } from './vercel-ai-tool-telemetry'; import * as dc from '../../utils/diagnosticsChannel'; import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; import { vercelAiModuleNames } from '../../orchestrion/config/vercel-ai'; @@ -23,6 +24,7 @@ const _vercelAIIntegration = ((options: VercelAiOptions = {}) => { waitForTracingChannelBinding(() => { subscribeVercelAiTracingChannel(dc.tracingChannel, options); }); + registerVercelAiToolTelemetry(); }, setup(client) { invokeOrchestrionInstrumentation(client, vercelAiModuleNames, instrumentVercelAiOrchestrion, [options]); diff --git a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts index f16d1f5eee02..2943aa7dddb2 100644 --- a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts +++ b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts @@ -52,7 +52,7 @@ import { import type { TracingChannel } from 'node:diagnostics_channel'; import { GEN_AI_TOOL_CALL_ID_ATTRIBUTE } from '../../ai/core/gen-ai-attributes'; import { isEveGenAiRecordingDefault } from './gen-ai-recording-mode'; -import type { GenAiOptions } from '../../ai/core/utils'; +import { getEvaluationOutputMessages, type GenAiOptions } from '../../ai/core/utils'; import { getProviderMetadataAttributes, LAST_STEP_ONLY_USAGE_KEYS } from '../../ai/vercel-ai'; import { WORKERS_AI_INTEGRATION_NAME } from '../../ai/workers-ai/constants'; import { bindTracingChannelToSpan } from '../../tracing-channel'; @@ -63,9 +63,9 @@ import { asNumber, asString, isReadableStream, type StreamedModelCallResult, sum * via `node:diagnostics_channel`. Events are discriminated by their `type` field. * @see https://github.com/vercel/ai/pull/15660 */ -const AI_SDK_TELEMETRY_TRACING_CHANNEL = 'ai:telemetry'; +export const AI_SDK_TELEMETRY_TRACING_CHANNEL = 'ai:telemetry'; -const ORIGIN = 'auto.vercelai.channel'; +const ORIGIN = 'auto.ai.vercelai'; // `gen_ai.operation.name` values, keyed to the span op they map to. const GEN_AI_OPERATION_SPAN_OPS = { @@ -689,7 +689,7 @@ function getOutputMessages( finishReason: string | undefined, ): string | undefined { if (type === 'experimental_evaluate' || type === 'experimental_decide') { - return stringify([{ type: 'evaluation', answers: withProviderConfidence(result) }]); + return getEvaluationOutputMessages(withProviderAnswerFields(result)); } // `languageModelCall` exposes the response as a `content` parts array; top-level results expose // `text` + `toolCalls`. Both normalize into the OTel `gen_ai.output.messages` assistant message. @@ -701,26 +701,36 @@ function getOutputMessages( } /** - * The AI SDK TypeSafe and OpenAI providers move each answer's `confidence` out of the answers into - * `providerMetadata..confidence` (keyed by question id). Put it back so decide answers keep it. + * Providers move answer fields into `providerMetadata`: TypeSafe and OpenAI as `.confidence[id]`, + * OpenRouter as `openrouter.answers[id].{confidence,legend}`. Put them back on the answers. */ -function withProviderConfidence(result: Record): unknown { +function withProviderAnswerFields(result: Record): unknown { const { answers, providerMetadata } = result; - const metadata = isObjectLike(providerMetadata) - ? [providerMetadata.typesafe, providerMetadata.openai].find(m => isObjectLike(m) && isObjectLike(m.confidence)) - : undefined; - const confidence = isObjectLike(metadata) && isObjectLike(metadata.confidence) ? metadata.confidence : undefined; - if (!confidence || !isObjectLike(answers)) { + if (!isObjectLike(answers) || !isObjectLike(providerMetadata)) { + return answers; + } + const confidenceById = [providerMetadata.typesafe, providerMetadata.openai] + .map(metadata => (isObjectLike(metadata) ? metadata.confidence : undefined)) + .find(isObjectLike); + const { openrouter } = providerMetadata; + const openrouterById = isObjectLike(openrouter) && isObjectLike(openrouter.answers) ? openrouter.answers : undefined; + if (!confidenceById && !openrouterById) { return answers; } return Object.fromEntries( - Object.entries(answers).map(([id, answer]) => [ - id, - isObjectLike(answer) && typeof confidence[id] === 'number' && answer.confidence === undefined - ? { ...answer, confidence: confidence[id] } - : answer, - ]), + Object.entries(answers).map(([id, answer]) => { + if (!isObjectLike(answer)) { + return [id, answer]; + } + const openrouterAnswer = openrouterById?.[id]; + const fields = isObjectLike(openrouterAnswer) ? openrouterAnswer : {}; + const { + confidence = asNumber(confidenceById?.[id]) ?? asNumber(fields.confidence), + legend = isObjectLike(fields.legend) ? fields.legend : undefined, + } = answer; + return [id, { ...answer, confidence, legend }]; + }), ); } @@ -841,7 +851,7 @@ export function captureToolError(span: Span, data: VercelAiChannelMessage, error captureException( error instanceof Error ? error : new Error(typeof error === 'string' ? error : 'Tool execution failed'), { - mechanism: { type: 'auto.vercelai.channel', handled: false }, + mechanism: { type: 'auto.ai.vercelai', handled: false }, }, ); }); diff --git a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-tool-telemetry.ts b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-tool-telemetry.ts new file mode 100644 index 000000000000..ebfe044bcf02 --- /dev/null +++ b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-tool-telemetry.ts @@ -0,0 +1,63 @@ +import { getActiveSpan, spanToJSON } from '@sentry/core'; +import { GEN_AI_TOOL_CALL_ID_ATTRIBUTE } from '../../ai/core/gen-ai-attributes'; +import * as dc from '../../utils/diagnosticsChannel'; +import { AI_SDK_TELEMETRY_TRACING_CHANNEL, type VercelAiChannelMessage } from './vercel-ai-dc-subscriber'; + +interface ToolOutput { + type: 'tool-result' | 'tool-error'; + toolCallId: string; + output?: unknown; +} + +/** The subset of `ai`'s `Telemetry` integration interface this uses. */ +interface TelemetryIntegration { + executeTool?( + options: Record & { toolCallId: string; execute: () => PromiseLike }, + ): PromiseLike; + onToolExecutionEnd?(event: { toolOutput: ToolOutput }): void; +} + +const openToolCalls = new Map(); + +/** + * Register a global `ai` (>= 7) telemetry integration that publishes tool executions to the + * `ai:telemetry` channel. Frameworks such as eve (>= 0.84) run tools outside `ai`'s tool loop, so `ai` + * publishes nothing for them, but they still call registered telemetry integrations. + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry + */ +export function registerVercelAiToolTelemetry(): void { + const channel = dc.tracingChannel(AI_SDK_TELEMETRY_TRACING_CHANNEL); + const carrier = globalThis as { AI_SDK_TELEMETRY_INTEGRATIONS?: TelemetryIntegration[] }; + + (carrier.AI_SDK_TELEMETRY_INTEGRATIONS ??= []).push({ + executeTool({ execute, ...event }) { + // `ai`'s own tool loop calls this inside its own `executeTool` channel span. + const activeSpan = getActiveSpan(); + if (activeSpan && spanToJSON(activeSpan).attributes?.[GEN_AI_TOOL_CALL_ID_ATTRIBUTE] === event.toolCallId) { + return execute(); + } + + const message: VercelAiChannelMessage = { type: 'executeTool', event }; + openToolCalls.set(event.toolCallId, message); + try { + return channel.start.runStores(message, execute); + } finally { + // Without a `result` this leaves the span open until `onToolExecutionEnd`. + channel.end.publish(message); + } + }, + + // The tool output only arrives here, after `execute` has settled. + onToolExecutionEnd({ toolOutput }) { + const message = openToolCalls.get(toolOutput.toolCallId); + if (!message) { + return; + } + openToolCalls.delete(toolOutput.toolCallId); + + // Same result shape as `ai`'s own `executeTool` events, so the subscriber records the output or captures the error. + message.result = { output: toolOutput.type === 'tool-error' ? toolOutput : toolOutput.output }; + channel.asyncEnd.publish(message); + }, + }); +} diff --git a/packages/server-utils/src/orchestrion/config/anthropic-ai.ts b/packages/server-utils/src/orchestrion/config/anthropic-ai.ts index 214b73a09fc0..bb7adcae14b2 100644 --- a/packages/server-utils/src/orchestrion/config/anthropic-ai.ts +++ b/packages/server-utils/src/orchestrion/config/anthropic-ai.ts @@ -31,6 +31,12 @@ export const anthropicAiConfig = [ module: { name: '@anthropic-ai/sdk', versionRange: '>=0.63.0 <1', filePath }, functionQuery: { className: 'Messages', methodName: 'toolRunner', kind: 'Sync' as const }, })), + // The constructor, not `sessions.events.toolRunner()`, so runners built directly (e.g. by `EnvironmentWorker`) are covered. + ...['lib/tools/SessionToolRunner.js', 'lib/tools/SessionToolRunner.mjs'].map(filePath => ({ + channelName: 'session-tool-runner', + module: { name: '@anthropic-ai/sdk', versionRange: '>=0.97.0 <1', filePath }, + functionQuery: { className: 'SessionToolRunner' }, + })), ] satisfies InstrumentationConfig[]; export const anthropicAiModuleNames = getModuleNames(anthropicAiConfig); @@ -39,4 +45,5 @@ export const anthropicAiChannels = { ANTHROPIC_CHAT: 'orchestrion:@anthropic-ai/sdk:chat', ANTHROPIC_MESSAGES_STREAM: 'orchestrion:@anthropic-ai/sdk:messages-stream', ANTHROPIC_TOOL_RUNNER: 'orchestrion:@anthropic-ai/sdk:tool-runner', + ANTHROPIC_SESSION_TOOL_RUNNER: 'orchestrion:@anthropic-ai/sdk:session-tool-runner', } as const; diff --git a/packages/server-utils/src/orchestrion/config/hono.ts b/packages/server-utils/src/orchestrion/config/hono.ts index eda1a8084ff3..f56ff5a12039 100644 --- a/packages/server-utils/src/orchestrion/config/hono.ts +++ b/packages/server-utils/src/orchestrion/config/hono.ts @@ -8,6 +8,8 @@ const honoInstrumentationConfig: InstrumentationConfig[] = [ { // `new Context()` runs once per request: where the Sentry middleware is injected and matched // middleware handlers are wrapped for spans. + // TODO(v12): Raise the minimum Hono version to 4.4.9, here and in the `@sentry/hono` peer range. + // Before 4.4.9, Hono passes `matchResult` to `new HonoRequest()`, so this hook does nothing. channelName: 'context', module: { name: 'hono', versionRange: '>=4.0.0 <5', filePath: /^dist\/(?:cjs\/)?context\.js$/ }, functionQuery: { className: 'Context' }, diff --git a/packages/server-utils/src/orchestrion/config/openai-compatible.ts b/packages/server-utils/src/orchestrion/config/openai-compatible.ts index 7decaedb3feb..d19dc8d2ee45 100644 --- a/packages/server-utils/src/orchestrion/config/openai-compatible.ts +++ b/packages/server-utils/src/orchestrion/config/openai-compatible.ts @@ -7,21 +7,25 @@ import type { InstrumentationConfig } from '../apmTypes'; * a thenable `APIPromise`. Sync tracing preserves its lazy body parsing; Auto would trigger it via `.then()`. * A streaming call resolves to the same OpenAI-style async-iterable `Stream` the openai integration consumes. These * SDKs ship dual CJS/ESM and the matcher compares `filePath` exactly, hence one entry per built file. + * `classNames` covers SDKs that moved `create` onto a base class while keeping this layout. * * Because the wire format is OpenAI-compatible, the span building, streaming and response parsing are all * reused from `ai/openai`; only the module name, channels and `gen_ai.provider.name` differ per provider. */ -export function openAiCompatibleConfig(module: { name: string; versionRange: string }): InstrumentationConfig[] { +export function openAiCompatibleConfig( + module: { name: string; versionRange: string }, + classNames = { chat: 'Completions', embeddings: 'Embeddings' }, +): InstrumentationConfig[] { return [ ...['resources/chat/completions.js', 'resources/chat/completions.mjs'].map(filePath => ({ channelName: 'chat', module: { ...module, filePath }, - functionQuery: { className: 'Completions', methodName: 'create', kind: 'Sync' as const }, + functionQuery: { className: classNames.chat, methodName: 'create', kind: 'Sync' as const }, })), ...['resources/embeddings.js', 'resources/embeddings.mjs'].map(filePath => ({ channelName: 'embeddings', module: { ...module, filePath }, - functionQuery: { className: 'Embeddings', methodName: 'create', kind: 'Sync' as const }, + functionQuery: { className: classNames.embeddings, methodName: 'create', kind: 'Sync' as const }, })), ]; } diff --git a/packages/server-utils/src/orchestrion/config/together-ai.ts b/packages/server-utils/src/orchestrion/config/together-ai.ts index 82a7f4ed006e..6ad5eccf0a3a 100644 --- a/packages/server-utils/src/orchestrion/config/together-ai.ts +++ b/packages/server-utils/src/orchestrion/config/together-ai.ts @@ -4,10 +4,14 @@ import { getModuleNames } from './module-names'; import { openAiCompatibleConfig } from './openai-compatible'; // `together-ai` is a Stainless-generated, OpenAI-compatible SDK — see `openAiCompatibleConfig`. -export const togetherAiConfig = openAiCompatibleConfig({ - name: 'together-ai', - versionRange: '>=0.6.0 <1', -}) satisfies InstrumentationConfig[]; +export const togetherAiConfig = [ + ...openAiCompatibleConfig({ name: 'together-ai', versionRange: '>=0.6.0 <1' }), + // 1.x defines `create` on `BaseCompletions`/`BaseEmbeddings`; `Completions`/`Embeddings` are empty subclasses. + ...openAiCompatibleConfig( + { name: 'together-ai', versionRange: '>=1.0.0 <2' }, + { chat: 'BaseCompletions', embeddings: 'BaseEmbeddings' }, + ), +] satisfies InstrumentationConfig[]; export const togetherAiModuleNames = getModuleNames(togetherAiConfig); diff --git a/packages/server-utils/src/orchestrion/instrumentation.ts b/packages/server-utils/src/orchestrion/instrumentation.ts index ded8aa30ac68..0dbddefc4da7 100644 --- a/packages/server-utils/src/orchestrion/instrumentation.ts +++ b/packages/server-utils/src/orchestrion/instrumentation.ts @@ -1,6 +1,6 @@ import * as diagnosticsChannel from '../utils/diagnosticsChannel'; import type { Client } from '@sentry/core'; -import { addNonEnumerableProperty, debug, waitForTracingChannelBinding } from '@sentry/core'; +import { addNonEnumerableProperty, debug, GLOBAL_OBJ, waitForTracingChannelBinding } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import { getOrchestrionInjectedModules } from './detect'; @@ -13,10 +13,13 @@ const INSTRUMENTED = Symbol.for('SentryOrchestrionInstrumented'); // oxlint-disable-next-line typescript/no-explicit-any type InstrumentationFn = ((...args: any[]) => void) & { [INSTRUMENTED]?: boolean }; -const globalAny = globalThis as { Bun?: unknown; Deno?: unknown }; +const globalAny = globalThis as { Bun?: unknown; Deno?: unknown; navigator?: { userAgent?: string } }; const isBun = typeof globalAny.Bun !== 'undefined'; const isDeno = typeof globalAny.Deno !== 'undefined'; +// Labels already warned about, so a client per request does not repeat the warning. +const warnedMissingCloudflarePlugin = new Set(); + /** * Run an integration's channel-subscription callback once one of its modules is * orchestrion-injected, and never more than once. @@ -113,6 +116,21 @@ export function invokeOrchestrionInstrumentation { diff --git a/packages/bun/src/utils/streaming.ts b/packages/server-utils/src/utils/monitorStream.ts similarity index 80% rename from packages/bun/src/utils/streaming.ts rename to packages/server-utils/src/utils/monitorStream.ts index d19466a6a035..6b4651f990d1 100644 --- a/packages/bun/src/utils/streaming.ts +++ b/packages/server-utils/src/utils/monitorStream.ts @@ -1,3 +1,5 @@ +import { isError } from '@sentry/core'; + /** Monitor the source without reading ahead or treating cancellation as a source failure. */ export function monitorStream( stream: ReadableStream, @@ -6,8 +8,12 @@ export function monitorStream( ): ReadableStream { const reader = stream.getReader(); // A cancelled reader closes normally; only a source failure should be captured as an error. + // A source that stops on `request.signal` errors with an `AbortError` when the client + // disconnects, which is a cancellation too. void reader.closed.then(onDone, error => { - onError(error); + if (!(isError(error) && error.name === 'AbortError')) { + onError(error); + } onDone(); }); diff --git a/packages/server-utils/src/utils/wrapServerRequestHandler.ts b/packages/server-utils/src/utils/wrapServerRequestHandler.ts new file mode 100644 index 000000000000..447764ddf5e5 --- /dev/null +++ b/packages/server-utils/src/utils/wrapServerRequestHandler.ts @@ -0,0 +1,296 @@ +import { + CLIENT_ADDRESS, + CLIENT_PORT, + HTTP_REQUEST_BODY_SIZE, + NETWORK_PROTOCOL_NAME, + SENTRY_OP, + SENTRY_SEGMENT_NAME_SOURCE, + USER_AGENT_ORIGINAL, +} from '@sentry/conventions/attributes'; +import { HTTP_SERVER } from '@sentry/conventions/op'; +import type { Client, Mechanism, Scope, Span, SpanAttributes } from '@sentry/core'; +import { + captureBodyFromWinterCGRequest, + captureException, + continueTrace, + getHttpSpanDetailsFromUrlObject, + hasSpansEnabled, + hasSpanStreamingEnabled, + httpHeadersToSpanAttributes, + HTTP_SPAN_NAME_FALLBACK, + parseStringToURLObject, + safeCallback, + setHttpStatus, + SPAN_STATUS_ERROR, + startSpanManual, + winterCGHeadersToDict, + winterCGRequestToRequestData, +} from '@sentry/core'; +import type { ServerRequestOptions } from '@sentry/core/server'; +import { + classifyResponseStreaming, + getClientIPAddress, + isStaticAssetRequest, + startRequestSession, +} from '@sentry/core/server'; +import { DEBUG_BUILD } from '../debug-build'; +import { monitorStream } from './monitorStream'; + +export interface ServerRequestHandlerConfig { + request: Request; + client: Client; + /** The isolation scope of this request. */ + isolationScope: Scope; + /** The options of the runtime's integration for incoming requests. */ + options: ServerRequestOptions | undefined; + /** Origin of the span, e.g. `auto.http.deno`. */ + origin: string; + /** Mechanism of errors that the handler or the response stream throws. */ + mechanism: Mechanism; + /** The address and port of the socket, if the runtime knows them. */ + getSocketAddress?: () => { address: string; port?: number } | undefined; + /** The matched route of the server framework, e.g. `/users/:id`. Sets `http.route` and the span name. */ + routeName?: string; + /** Span attributes that only the runtime knows, e.g. the route parameters. */ + attributes?: SpanAttributes; +} + +/** + * Wraps a handler of a web `Request`, as used by `Deno.serve`, `Bun.serve` and Cloudflare Workers. + * + * It records the request session, attaches the request data and body, continues the incoming + * trace, creates the `http.server` span, and captures errors that the handler throws. The rules + * follow core's `node:http` server code: + * + * - `OPTIONS` and `HEAD` requests get no span and no body capture. + * - The static asset filter applies only to `GET`. + * - The span and the session of a streamed response end when the stream finishes. + * + * The handler can return no response, as Bun's `fetch` does after a WebSocket upgrade. + */ +export async function wrapServerRequestHandler( + config: ServerRequestHandlerConfig, + handler: () => R | Promise, +): Promise { + const { request, client, isolationScope, options, mechanism } = config; + const method = request.method.toUpperCase(); + + const endSession = options?.sessions + ? startRequestSession(client, { + requestIsolationScope: isolationScope, + sessionFlushingDelayMS: options.sessionFlushingDelayMS, + }) + : undefined; + + const socketAddress = config.getSocketAddress?.(); + isolationScope.setSDKProcessingMetadata({ + normalizedRequest: winterCGRequestToRequestData(request), + ipAddress: socketAddress?.address, + }); + + if (method === 'OPTIONS' || method === 'HEAD') { + try { + return await handler(); + } catch (e) { + captureException(e, { mechanism }); + throw e; + } finally { + endSession?.(); + } + } + + const dataCollection = client.getDataCollectionOptions(); + const effectiveBodySize = + options?.maxRequestBodySize ?? (dataCollection.httpBodies.includes('incomingRequest') ? 'medium' : 'none'); + const ignoreRequestBody = options?.ignoreRequestBody; + if ( + method !== 'GET' && + effectiveBodySize !== 'none' && + // If the filter throws, skip the body so it cannot leak data the filter was meant to hide. + !( + ignoreRequestBody && + safeCallback( + DEBUG_BUILD ? 'Error in `ignoreRequestBody`, skipping the body:' : '', + () => ignoreRequestBody(request.url, request), + () => true, + ) + ) + ) { + await captureBodyFromWinterCGRequest(request, isolationScope, effectiveBodySize); + } + + const urlObject = parseStringToURLObject(request.url); + const urlPath = urlObject ? `${urlObject.pathname}${urlObject.search}` : '/'; + const createSpan = shouldCreateSpan(client, options, request, urlPath); + + return continueTrace( + { + sentryTrace: request.headers.get('sentry-trace') || '', + baggage: request.headers.get('baggage'), + }, + async () => { + if (!createSpan) { + let response: R; + try { + response = await handler(); + } catch (e) { + captureException(e, { mechanism }); + endSession?.(); + throw e; + } + if (response) { + isolationScope.setContext('response', { status_code: response.status }); + } + return onResponseDone(response, mechanism, () => endSession?.()); + } + + const [rawName, attributes] = getHttpSpanDetailsFromUrlObject( + urlObject, + 'server', + config.origin, + request, + config.routeName, + client, + ); + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. + // A `route` source means the name already is (e.g. the `/` path), so it is kept as-is. + const name = + attributes[SENTRY_SEGMENT_NAME_SOURCE] === 'route' || !hasSpanStreamingEnabled(client) + ? rawName + : method || HTTP_SPAN_NAME_FALLBACK; + + const contentLength = request.headers.get('content-length'); + if (contentLength) { + attributes[HTTP_REQUEST_BODY_SIZE] = parseInt(contentLength, 10); + } + const userAgent = request.headers.get('user-agent'); + if (userAgent) { + attributes[USER_AGENT_ORIGINAL] = userAgent; + } + + const headers = winterCGHeadersToDict(request.headers); + if (dataCollection.userInfo) { + // `client.address` is the originating client, so a forwarding header wins over the socket, which + // behind a proxy holds the proxy's address. The socket port is the proxy's too, so `client.port` + // stays unset then. + const forwardedAddress = getClientIPAddress(headers); + if (forwardedAddress) { + attributes[CLIENT_ADDRESS] = forwardedAddress; + } else if (socketAddress) { + attributes[CLIENT_ADDRESS] = socketAddress.address; + if (socketAddress.port !== undefined) { + attributes[CLIENT_PORT] = socketAddress.port; + } + } + } + + // describes the OSI application-layer protocol (http), not the scheme (might be https) + attributes[NETWORK_PROTOCOL_NAME] = 'http'; + Object.assign(attributes, httpHeadersToSpanAttributes(headers, dataCollection)); + Object.assign(attributes, config.attributes); + attributes[SENTRY_OP] = HTTP_SERVER; + + return startSpanManual({ name, attributes }, async span => { + const { onSpanCreated, onSpanEnd } = options ?? {}; + if (onSpanCreated) { + safeCallback( + DEBUG_BUILD ? 'Error in `onSpanCreated`:' : '', + () => onSpanCreated(span, request), + () => undefined, + ); + } + + let response: R; + try { + response = await handler(); + } catch (e) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + span.end(); + captureException(e, { mechanism }); + endSession?.(); + throw e; + } + + if (response) { + setHttpStatus(span, response.status); + isolationScope.setContext('response', { status_code: response.status }); + span.setAttributes( + httpHeadersToSpanAttributes(responseHeadersToDict(response.headers), dataCollection, 'response'), + ); + } + + return onResponseDone( + response, + mechanism, + () => { + if (onSpanEnd && response) { + safeCallback( + DEBUG_BUILD ? 'Error in `onSpanEnd`:' : '', + () => onSpanEnd(span, request, response), + () => undefined, + ); + } + span.end(); + endSession?.(); + }, + span, + ); + }); + }, + ); +} + +function shouldCreateSpan( + client: Client, + options: ServerRequestOptions | undefined, + request: Request, + urlPath: string, +): boolean { + if (!(options?.spans ?? hasSpansEnabled(client.getOptions()))) { + return false; + } + if (options?.ignoreStaticAssets && request.method.toUpperCase() === 'GET' && isStaticAssetRequest(urlPath)) { + return false; + } + const ignoreIncomingRequests = options?.ignoreIncomingRequests; + return !( + ignoreIncomingRequests && + safeCallback( + DEBUG_BUILD ? 'Error in `ignoreIncomingRequests`, creating the span:' : '', + () => ignoreIncomingRequests(urlPath, request), + () => false, + ) + ); +} + +/** + * Calls `onDone` when the response is done: at once, or when a streamed body finishes. + * Returns the response to send. + */ +function onResponseDone( + response: R, + mechanism: Mechanism, + onDone: () => void, + span?: Span, +): R { + if (response?.body && !response.body.locked && classifyResponseStreaming(response).isStreaming) { + const body = monitorStream(response.body, onDone, error => { + span?.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { mechanism }); + }); + // Passing the original response as the init keeps its status, status text and headers. + return new Response(body, response) as R; + } + onDone(); + return response; +} + +// `Headers.forEach` lists each `Set-Cookie` header on its own, so a plain dict keeps only the last one. +function responseHeadersToDict(headers: Headers): Record { + const dict: Record = winterCGHeadersToDict(headers); + const setCookie = typeof headers.getSetCookie === 'function' ? headers.getSetCookie() : []; + if (setCookie.length) { + dict['set-cookie'] = setCookie; + } + return dict; +} diff --git a/packages/server-utils/test/ai/lib/tracing/anthropic-ai-tool-runner.test.ts b/packages/server-utils/test/ai/lib/tracing/anthropic-ai-tool-runner.test.ts index 4c99901f21a5..f27b5e85a62d 100644 --- a/packages/server-utils/test/ai/lib/tracing/anthropic-ai-tool-runner.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/anthropic-ai-tool-runner.test.ts @@ -32,9 +32,31 @@ class FakeRunner { } } +// Mirrors the SDK session runner: it keeps `opts.tools` as `tools` and passes the call event as both `toolUse` and `toolUseBlock`. +class FakeSessionRunner { + public readonly tools: Tool[]; + public constructor(opts: { tools: Tool[] }) { + this.tools = opts.tools; + } + public async dispatch(name: string, toolUse: { id: string; input: unknown }): Promise<{ isError: boolean }> { + const tool = this.tools.find(t => t.name === name)!; + try { + await tool.run(toolUse.input, { toolUse, toolUseBlock: toolUse, signal: new AbortController().signal }); + return { isError: false }; + } catch { + return { isError: true }; + } + } +} + function fakeClient() { return { beta: { + sessions: { + events: { + toolRunner: vi.fn((_sessionId: string, opts: { tools: Tool[] }) => new FakeSessionRunner(opts)), + }, + }, messages: { create: vi.fn(), toolRunner: vi.fn((params: Params) => { @@ -263,4 +285,50 @@ describe('instrumentAnthropicAiClient tool runner', () => { expect(() => instrumented.beta.messages.toolRunner(undefined as unknown as Params)).toThrow('params required'); }); + + describe('sessions.events.toolRunner()', () => { + it('wraps each tool call of the session runner in an execute_tool span', async () => { + const weather = tool('weather', async () => 'sunny'); + + const runner = instrumentAnthropicAiClient(fakeClient()).beta.sessions.events.toolRunner('sesn_1', { + tools: [weather], + }); + const parent = await startSpan({ name: 'agent' }, async span => { + await runner.dispatch('weather', { id: 'sevt_1', input: { city: 'Vienna' } }); + return spanToStaticSpanJSON(span); + }); + + expect(toolSpans()).toEqual([ + expect.objectContaining({ + description: 'execute_tool weather', + parent_span_id: parent.span_id, + origin: 'auto.ai.anthropic', + status: 'ok', + data: expect.objectContaining({ + [GEN_AI_TOOL_NAME]: 'weather', + [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: 'sevt_1', + }), + }), + ]); + }); + + it('captures the error of a failing tool, which the session runner turns into a result', async () => { + const explode = tool('explode', async () => { + throw new Error('disk full'); + }); + + const runner = instrumentAnthropicAiClient(fakeClient()).beta.sessions.events.toolRunner('sesn_1', { + tools: [explode], + }); + const outcome = await runner.dispatch('explode', { id: 'sevt_2', input: {} }); + + expect(outcome).toEqual({ isError: true }); + expect(toolSpans()[0]!.status).toBe('internal_error'); + await vi.waitFor(() => expect(events).toHaveLength(1)); + expect(events[0]!.exception?.values?.[0]).toMatchObject({ + value: 'disk full', + mechanism: { type: 'auto.ai.anthropic', handled: true }, + }); + }); + }); }); diff --git a/packages/server-utils/test/ai/lib/tracing/anthropic-ai-usage.test.ts b/packages/server-utils/test/ai/lib/tracing/anthropic-ai-usage.test.ts index aa62ff8d78ba..c506fbcb9387 100644 --- a/packages/server-utils/test/ai/lib/tracing/anthropic-ai-usage.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/anthropic-ai-usage.test.ts @@ -124,3 +124,150 @@ describe.each(['response', 'async iterable', 'message stream'] as const)('Anthro }); }); }); + +describe.each(['async iterable', 'message stream'] as const)( + 'Anthropic streamed input usage from message_delta (%s)', + mode => { + it('uses the cumulative input and cache counts of the final message_delta', async () => { + const { span, attributes } = createMockSpan(); + // With server tools, message_delta reports input and cache counts that grew past message_start's. + const events: AnthropicAiStreamingEvent[] = [ + { + type: 'message_start', + message: { + id: 'msg_server_tool', + type: 'message', + model: 'claude-sonnet-4-6', + role: 'assistant', + content: [], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 2285, + output_tokens: 25, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + }, + { + type: 'message_delta', + usage: { + input_tokens: 2292, + output_tokens: 150, + cache_creation_input_tokens: 12032, + cache_read_input_tokens: 0, + }, + }, + { type: 'message_stop' }, + ]; + + if (mode === 'async iterable') { + const stream = (async function* () { + yield* events; + })(); + for await (const _ of instrumentAsyncIterableStream(stream, span, false)) { + void _; + } + } else { + const stream = instrumentMessageStream(new EventEmitter(), span, false); + for (const event of events) { + stream.emit('streamEvent', event); + } + stream.emit('message', {}); + } + + expect(attributes).toMatchObject({ + [GEN_AI_USAGE_INPUT_TOKENS]: 2292 + 12032, + [GEN_AI_USAGE_OUTPUT_TOKENS]: 150, + [GEN_AI_USAGE_TOTAL_TOKENS]: 2292 + 12032 + 150, + [GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS]: 12032, + [GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS]: 0, + }); + }); + + it('keeps the message_start input counts that message_delta omits or sends as null', async () => { + const { span, attributes } = createMockSpan(); + const events: AnthropicAiStreamingEvent[] = [ + { + type: 'message_start', + message: { + id: 'msg_keep', + type: 'message', + model: 'claude-sonnet-4-6', + role: 'assistant', + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 1, cache_read_input_tokens: 40 }, + }, + }, + { type: 'message_delta', usage: { input_tokens: null, cache_read_input_tokens: null, output_tokens: 12 } }, + { type: 'message_stop' }, + ]; + + if (mode === 'async iterable') { + const stream = (async function* () { + yield* events; + })(); + for await (const _ of instrumentAsyncIterableStream(stream, span, false)) { + void _; + } + } else { + const stream = instrumentMessageStream(new EventEmitter(), span, false); + for (const event of events) { + stream.emit('streamEvent', event); + } + stream.emit('message', {}); + } + + expect(attributes).toMatchObject({ + [GEN_AI_USAGE_INPUT_TOKENS]: 50, + [GEN_AI_USAGE_OUTPUT_TOKENS]: 12, + [GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS]: 40, + }); + }); + + it('takes the input count from message_delta when message_start reports zero', async () => { + const { span, attributes } = createMockSpan(); + const events: AnthropicAiStreamingEvent[] = [ + { + type: 'message_start', + message: { + id: 'msg_zero_start', + type: 'message', + model: 'claude-sonnet-4-6', + role: 'assistant', + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 1 }, + }, + }, + { type: 'message_delta', usage: { input_tokens: 1500, output_tokens: 40 } }, + { type: 'message_stop' }, + ]; + + if (mode === 'async iterable') { + const stream = (async function* () { + yield* events; + })(); + for await (const _ of instrumentAsyncIterableStream(stream, span, false)) { + void _; + } + } else { + const stream = instrumentMessageStream(new EventEmitter(), span, false); + for (const event of events) { + stream.emit('streamEvent', event); + } + stream.emit('message', {}); + } + + expect(attributes).toMatchObject({ + [GEN_AI_USAGE_INPUT_TOKENS]: 1500, + [GEN_AI_USAGE_OUTPUT_TOKENS]: 40, + [GEN_AI_USAGE_TOTAL_TOKENS]: 1540, + }); + }); + }, +); diff --git a/packages/server-utils/test/ai/lib/tracing/flue.test.ts b/packages/server-utils/test/ai/lib/tracing/flue.test.ts index bb3712d6ac32..e39ed3fd3bfb 100644 --- a/packages/server-utils/test/ai/lib/tracing/flue.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/flue.test.ts @@ -1,16 +1,22 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; import type { Event, Span } from '@sentry/core'; import { _INTERNAL_clearAiProviderSkips, _INTERNAL_shouldSkipAiProviderWrapping, _INTERNAL_skipAiProviderWrapping, + getIsolationScope, getMainCarrier, + Scope, + setAsyncContextStrategy, setCurrentClient, spanToStaticSpanJSON, startInactiveSpan, startSpan, + withActiveSpan, + withIsolationScope, } from '@sentry/core'; import { ANTHROPIC_AI_INTEGRATION_NAME } from '../../../../src/ai/anthropic-ai/constants'; +import { setAsyncLocalStorageAsyncContextStrategy } from '../../../../src/async-context'; import { createFlueInstrumentation } from '../../../../src/ai/flue'; import { MAX_TRACKED_FLUE_SPANS } from '../../../../src/ai/flue/constants'; import { GOOGLE_GENAI_INTEGRATION_NAME } from '../../../../src/ai/google-genai/constants'; @@ -223,6 +229,114 @@ describe('createFlueInstrumentation', () => { }); }); + // On Node, Flue runs every submission from one claim loop that keeps the async context of the + // call that started it, so the run has to be put back where it was admitted. + describe('admission context', () => { + const SUBMISSION_OP = { type: 'agent', operationId: 'sub_1' }; + + function admit(): void { + instrumentation.observe({ type: 'submission_queued', submissionId: 'sub_1' }, {}); + } + + /** Run the submission the way the claim loop does: wrapper first, then the spanned operation. */ + function runSubmission(): Promise { + return instrumentation.interceptor(SUBMISSION_OP, AGENT_CTX, () => + instrumentation.interceptor(AGENT_OP, AGENT_CTX, async () => undefined), + ); + } + + it('parents the agent span to the span that admitted the submission, not the one the loop carries', async () => { + const admitting = startInactiveSpan({ name: 'POST /agents/hello/b' }); + withActiveSpan(admitting, admit); + + await startSpan({ name: 'POST /agents/hello/a' }, () => runSubmission()); + + const agent = findSpan('invoke_agent Hello'); + expect(agent?.trace_id).toBe(spanToStaticSpanJSON(admitting).trace_id); + expect(agent?.parent_span_id).toBe(admitting.spanContext().spanId); + }); + + it('restores the isolation scope the submission was admitted in', async () => { + // The default stack strategy ignores the scope it is handed. + setAsyncLocalStorageAsyncContextStrategy(); + onTestFinished(() => setAsyncContextStrategy(undefined)); + + const admittingScope = new Scope(); + withIsolationScope(admittingScope, admit); + + let runScope: Scope | undefined; + await instrumentation.interceptor(SUBMISSION_OP, AGENT_CTX, async () => { + runScope = getIsolationScope(); + }); + + expect(runScope).toBe(admittingScope); + }); + + it('starts a new trace when nothing was active at admission', async () => { + admit(); + + let loopTraceId: string | undefined; + await startSpan({ name: 'POST /agents/hello/a' }, span => { + loopTraceId = spanToStaticSpanJSON(span).trace_id; + return runSubmission(); + }); + + const agent = findSpan('invoke_agent Hello'); + expect(agent?.trace_id).toMatch(/^[0-9a-f]{32}$/); + expect(agent?.trace_id).not.toBe(loopTraceId); + }); + + // Each Cloudflare turn runs from its own Durable Object alarm, which already starts a fresh trace. + it('leaves the context alone on Cloudflare Workers', async () => { + vi.stubGlobal('navigator', { userAgent: 'Cloudflare-Workers' }); + onTestFinished(() => { + vi.unstubAllGlobals(); + }); + withActiveSpan(startInactiveSpan({ name: 'POST /agents/hello/b' }), admit); + + let alarmSpanId: string | undefined; + await startSpan({ name: 'alarm' }, span => { + alarmSpanId = span.spanContext().spanId; + return runSubmission(); + }); + + expect(findSpan('invoke_agent Hello')?.parent_span_id).toBe(alarmSpanId); + }); + + // A submission admitted by another process, replayed by idempotency key, or already settled has + // no record here, but the loop still carries the context of whichever request started it. + it('drops the loop context when nothing was recorded for the submission', async () => { + withActiveSpan(startInactiveSpan({ name: 'POST /agents/hello/b' }), admit); + instrumentation.observe({ type: 'submission_settled', submissionId: 'sub_1' }, {}); + + let loopTraceId: string | undefined; + await startSpan({ name: 'POST /agents/hello/a' }, span => { + loopTraceId = span.spanContext().traceId; + return runSubmission(); + }); + + const agent = findSpan('invoke_agent Hello'); + expect(agent?.parent_span_id).toBeUndefined(); + expect(agent?.trace_id).toMatch(/^[0-9a-f]{32}$/); + expect(agent?.trace_id).not.toBe(loopTraceId); + }); + + it('continues the replayed traceparent when nothing was recorded for the submission', async () => { + const traceId = 'cccccccccccccccccccccccccccccccc'; + await startSpan({ name: 'POST /agents/hello/a' }, () => + instrumentation.interceptor(SUBMISSION_OP, AGENT_CTX, () => + instrumentation.interceptor( + AGENT_OP, + { ...AGENT_CTX, traceCarrier: { traceparent: `00-${traceId}-dddddddddddddddd-01` } }, + async () => undefined, + ), + ), + ); + + expect(findSpan('invoke_agent Hello')?.trace_id).toBe(traceId); + }); + }); + // The spanned operation carries no `agentName` — only the submission wrapper does, and that opens // no span. The name arrives on the observations instead. it('names the agent span from the observations', async () => { diff --git a/packages/server-utils/test/ai/lib/tracing/langchain-invoke-agent.test.ts b/packages/server-utils/test/ai/lib/tracing/langchain-invoke-agent.test.ts index d3a011cc3f32..f32ee4fb7528 100644 --- a/packages/server-utils/test/ai/lib/tracing/langchain-invoke-agent.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/langchain-invoke-agent.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { GEN_AI_AGENT_NAME, GEN_AI_CONVERSATION_ID, @@ -10,6 +10,7 @@ import { import { GEN_AI_DECIDE } from '@sentry/conventions/op'; import { conversationIdIntegration, + debug, getClient, getMainCarrier, setConversationId, @@ -20,6 +21,7 @@ import { } from '@sentry/core'; import type { ClientOptions, Span } from '@sentry/core'; import { createLangChainCallbackHandler } from '../../../../src/ai/langchain'; +import { MAX_TRACKED_LANGCHAIN_RUNS } from '../../../../src/ai/langchain/constants'; import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; beforeEach(() => { @@ -320,6 +322,39 @@ describe('LangChain callback handler run tree', () => { }); }); + it('ends the oldest run once too many runs are open, and warns once', () => { + const endedSpans = setupClient(); + const warn = vi.spyOn(debug, 'warn').mockImplementation(() => undefined); + const handler = createLangChainCallbackHandler(); + + handler.handleChainStart?.({}, {}, 'abandoned', undefined, undefined, undefined, undefined, 'abandoned'); + for (let i = 0; i <= MAX_TRACKED_LANGCHAIN_RUNS; i++) { + handler.handleChainStart?.({}, {}, `run-${i}`, undefined, undefined, undefined, undefined, `run-${i}`); + } + expect(endedSpans.map(span => spanToJSON(span).name)).toEqual(['chain abandoned', 'chain run-0']); + expect(warn).toHaveBeenCalledTimes(1); + + handler.handleChainEnd?.({}, 'abandoned'); + expect(endedSpans).toHaveLength(2); + warn.mockRestore(); + }); + + it('keeps every ancestor of a busy run open', () => { + const endedSpans = setupClient(); + const handler = createLangChainCallbackHandler(); + + handler.handleChainStart?.({}, {}, 'outer', undefined, undefined, undefined, undefined, 'outer'); + handler.handleChainStart?.({}, {}, 'inner', 'outer', undefined, undefined, undefined, 'inner'); + for (let i = 0; i < MAX_TRACKED_LANGCHAIN_RUNS; i++) { + handler.handleChatModelStart?.(chatModel, [[]], `chat-${i}`, 'inner'); + } + handler.handleChainEnd?.({}, 'inner'); + handler.handleChainEnd?.({}, 'outer'); + + const chainNames = endedSpans.map(span => spanToJSON(span).name).filter(name => name.startsWith('chain')); + expect(chainNames).toEqual(['chain inner', 'chain outer']); + }); + it('lets an id from Sentry.setConversationId() win over one from a child run', () => { const endedSpans = setupClient(); getClient()!.addIntegration(conversationIdIntegration()); diff --git a/packages/server-utils/test/ai/lib/tracing/openai-streaming-tool-calls.test.ts b/packages/server-utils/test/ai/lib/tracing/openai-streaming-tool-calls.test.ts new file mode 100644 index 000000000000..cd39e060c9bc --- /dev/null +++ b/packages/server-utils/test/ai/lib/tracing/openai-streaming-tool-calls.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest'; +import type { Span } from '@sentry/core'; +import { GEN_AI_RESPONSE_TOOL_CALLS } from '@sentry/conventions/attributes'; +import { instrumentStream } from '../../../../src/ai/openai/streaming'; +import type { ChatCompletionChunk, ChatCompletionToolCall } from '../../../../src/ai/openai/types'; + +function createMockSpan(): { span: Span; attributes: Record } { + const attributes: Record = {}; + let isEnded = false; + const span = { + isRecording: () => !isEnded, + setAttribute: (key: string, value: unknown) => { + attributes[key] = value; + }, + setAttributes: (attrs: Record) => { + Object.assign(attributes, attrs); + }, + setStatus: () => {}, + end: () => { + isEnded = true; + }, + } as unknown as Span; + return { span, attributes }; +} + +function chunkWith(toolCalls: Array>): ChatCompletionChunk { + return { + id: 'chatcmpl-1', + object: 'chat.completion.chunk', + model: 'gemini-test', + choices: [{ index: 0, delta: { tool_calls: toolCalls } }], + } as unknown as ChatCompletionChunk; +} + +async function recordedToolCalls(chunks: ChatCompletionChunk[]): Promise>> { + const { span, attributes } = createMockSpan(); + async function* stream(): AsyncGenerator { + yield* chunks; + } + for await (const _ of instrumentStream(stream(), span, true)) { + void _; + } + return JSON.parse(attributes[GEN_AI_RESPONSE_TOOL_CALLS] as string) as Array>; +} + +describe('instrumentStream (openai chat completion tool calls)', () => { + it('keeps whole tool calls that carry an id and no index', async () => { + const toolCalls = await recordedToolCalls([ + chunkWith([ + { id: 'call_a', type: 'function', function: { name: 'lookup', arguments: '{"q":"a"}' } }, + { id: 'call_b', type: 'function', function: { name: 'search', arguments: '{"q":"b"}' } }, + ]), + ]); + + expect(toolCalls).toEqual([ + { id: 'call_a', type: 'function', function: { name: 'lookup', arguments: '{"q":"a"}' } }, + { id: 'call_b', type: 'function', function: { name: 'search', arguments: '{"q":"b"}' } }, + ]); + }); + + it('joins argument fragments by id, then onto the latest call when there is no id', async () => { + const toolCalls = await recordedToolCalls([ + chunkWith([{ id: 'call_a', type: 'function', function: { name: 'lookup', arguments: '{"q":' } }]), + chunkWith([{ id: 'call_a', function: { name: '', arguments: '"a"' } }]), + chunkWith([{ function: { name: '', arguments: '}' } }]), + ]); + + expect(toolCalls).toEqual([ + { id: 'call_a', type: 'function', function: { name: 'lookup', arguments: '{"q":"a"}' } }, + ]); + }); + + it('keeps named calls that have neither id nor index', async () => { + const toolCalls = await recordedToolCalls([ + chunkWith([{ function: { name: 'lookup', arguments: '{}' } }]), + chunkWith([{ function: { name: 'search', arguments: '{}' } }]), + ]); + + expect(toolCalls.map(call => (call.function as { name: string }).name)).toEqual(['lookup', 'search']); + }); + + it('does not let a later real index overwrite an earlier call without one', async () => { + const toolCalls = await recordedToolCalls([ + chunkWith([{ id: 'call_b', type: 'function', function: { name: 'search', arguments: '{}' } }]), + chunkWith([{ index: 0, id: 'call_a', function: { name: 'lookup', arguments: '{}' } }]), + ]); + + expect(toolCalls.map(call => call.id)).toEqual(['call_b', 'call_a']); + }); + + it('still groups indexed deltas by index', async () => { + const toolCalls = await recordedToolCalls([ + chunkWith([{ index: 0, id: 'call_a', function: { name: 'lookup', arguments: '{}' } }]), + chunkWith([{ index: 1, id: 'call_b', function: { name: 'search', arguments: '{' } }]), + chunkWith([{ index: 1, function: { name: '', arguments: '}' } }]), + ]); + + expect(toolCalls).toEqual([ + expect.objectContaining({ id: 'call_a', function: { name: 'lookup', arguments: '{}' } }), + expect.objectContaining({ id: 'call_b', function: { name: 'search', arguments: '{}' } }), + ]); + }); + + it('treats a null index like a missing one', async () => { + const toolCalls = await recordedToolCalls([ + chunkWith([ + { index: null, id: 'call_a', type: 'function', function: { name: 'lookup', arguments: '{"q":"a"}' } }, + { index: null, id: 'call_b', type: 'function', function: { name: 'search', arguments: '{"q":"b"}' } }, + ]), + chunkWith([{ index: null, function: { name: '', arguments: ' ' } }]), + ]); + + expect(toolCalls.map(call => call.id)).toEqual(['call_a', 'call_b']); + expect((toolCalls[1]?.function as { arguments: string }).arguments).toBe('{"q":"b"} '); + }); + + it('opens a new call when a different id lands on an index that is already taken', async () => { + const toolCalls = await recordedToolCalls([ + chunkWith([ + { index: 0, id: 'call_a', type: 'function', function: { name: 'lookup', arguments: '{"q":"a"}' } }, + { index: 0, id: 'call_b', type: 'function', function: { name: 'search', arguments: '{"q":"b"}' } }, + ]), + ]); + + expect(toolCalls).toEqual([ + { index: 0, id: 'call_a', type: 'function', function: { name: 'lookup', arguments: '{"q":"a"}' } }, + { index: 0, id: 'call_b', type: 'function', function: { name: 'search', arguments: '{"q":"b"}' } }, + ]); + }); + + it('continues the latest call opened on a reused index when the next delta has no id', async () => { + const toolCalls = await recordedToolCalls([ + chunkWith([{ index: 0, id: 'call_a', function: { name: 'lookup', arguments: '{}' } }]), + chunkWith([{ index: 0, id: 'call_b', function: { name: 'search', arguments: '{"q":' } }]), + chunkWith([{ index: 0, function: { name: '', arguments: '"b"}' } }]), + ]); + + expect(toolCalls.map(call => (call.function as { arguments: string }).arguments)).toEqual(['{}', '{"q":"b"}']); + }); + + it('does not split a call whose id is repeated on every delta of its index', async () => { + const toolCalls = await recordedToolCalls([ + chunkWith([{ index: 0, id: 'call_a', function: { name: 'lookup', arguments: '{"q":' } }]), + chunkWith([{ index: 0, id: 'call_a', function: { name: '', arguments: '"a"}' } }]), + ]); + + expect(toolCalls).toHaveLength(1); + expect((toolCalls[0]?.function as { arguments: string }).arguments).toBe('{"q":"a"}'); + }); + + it('keeps the order calls were opened when a reused index is followed by a higher index', async () => { + const toolCalls = await recordedToolCalls([ + chunkWith([ + { index: 0, id: 'call_a', function: { name: 'lookup', arguments: '{}' } }, + { index: 0, id: 'call_b', function: { name: 'search', arguments: '{}' } }, + { index: 1, id: 'call_c', function: { name: 'fetch', arguments: '{}' } }, + ]), + ]); + + expect(toolCalls.map(call => call.id)).toEqual(['call_a', 'call_b', 'call_c']); + }); +}); diff --git a/packages/server-utils/test/ai/lib/tracing/pi-durable.test.ts b/packages/server-utils/test/ai/lib/tracing/pi-durable.test.ts index d23b3310785e..5c35581138cf 100644 --- a/packages/server-utils/test/ai/lib/tracing/pi-durable.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/pi-durable.test.ts @@ -949,6 +949,106 @@ describe('instrumentPiDurableHarnessOptions', () => { expect(ids.every(id => id.endsWith(':1'))).toBe(true); }); + it('uses the provider session id of the conversation, also in a new Harness', async () => { + const sessionId = '019a2b3c-4d5e-7f60-8172-93a4b5c6d7e8'; + const harnessWithModels = (): PiHarnessOptions => { + const settle = generationPhase([{ run: { inputs: [5] } }, { settle: { status: 'done' } }]); + const task: PiTask = { + definition: { + name: 'pi.generation', + phases: { + request: async (taskRecord, runtime, context) => { + await harness.models!.completeSimple!(MODEL, { messages: [] }); + await settle.definition.phases.request!(taskRecord, runtime, context); + }, + }, + }, + }; + const harness: PiHarnessOptions = instrumentPiDurableHarnessOptions({ + models: { completeSimple: async () => ANSWER }, + registry: { + snapshot: () => ({ task: () => task, tasks: () => [task] }), + subscribe: () => () => undefined, + }, + }); + return harness; + }; + const runGenerationWithProviderDoc = async (harness: PiHarnessOptions): Promise => { + const live: PiLiveState = {}; + const runtime: PiTaskRuntime = { + taskId: 3, + conversationId: 1, + agent: async () => ({}), + commit: async (change: PiCommitChange) => { + await change({ doc: async () => live, settleSubmission: () => undefined }, undefined); + }, + // pi-durable rejects a read whose definition disagrees with the stored document. + snapshot: async (token, ownerId) => { + expect(token.definition).toEqual({ + kind: 'pi.provider', + version: 1, + scope: 'conversation', + history: 'latest', + fork: 'initial', + }); + return ownerId === 1 ? { sessionId } : undefined; + }, + }; + await harness.registry!.snapshot().task('pi.generation')!.definition.phases.request!( + undefined, + runtime, + undefined, + ); + }; + + await runGenerationWithProviderDoc(harnessWithModels()); + await runGenerationWithProviderDoc(harnessWithModels()); + + const ids = endedSpans + .map(span => spanToJSON(span)) + .filter( + span => span.attributes[SENTRY_OP] === GEN_AI_INVOKE_AGENT || span.attributes[SENTRY_OP] === GEN_AI_CHAT, + ) + .map(span => span.attributes[GEN_AI_CONVERSATION_ID]); + expect(ids).toEqual([sessionId, sessionId, sessionId, sessionId]); + }); + + it('keeps the harness prefix when the provider session id cannot be read', async () => { + const harness = harnessFor(generationPhase([{ run: { inputs: [5] } }, { settle: { status: 'done' } }])); + const live: PiLiveState = {}; + const runtime: PiTaskRuntime = { + taskId: 3, + conversationId: 1, + agent: async () => ({}), + commit: async (change: PiCommitChange) => { + await change({ doc: async () => live, settleSubmission: () => undefined }, undefined); + }, + snapshot: async () => { + throw new Error('Document pi.provider version 2 is newer than version 1'); + }, + }; + + await harness.registry!.snapshot().task('pi.generation')!.definition.phases.request!( + undefined, + runtime, + undefined, + ); + + expect(runSpans()[0]!.attributes[GEN_AI_CONVERSATION_ID]).toMatch(/^[0-9a-f]{32}:1$/); + }); + + it('uses the provider session id of the run for the tool calls of its conversation', async () => { + const runs = createRuns(); + startRun(4, runs).sessionId = '019a2b3c-4d5e-7f60-8172-93a4b5c6d7e8'; + const tool: PiTool = { name: 'get_weather', execute: async () => ({ content: [] }) }; + + await instrumentTool(tool, runs, {}).execute({}, { conversationId: 4 }, undefined); + + expect(spanToJSON(endedSpans[0]!).attributes[GEN_AI_CONVERSATION_ID]).toBe( + '019a2b3c-4d5e-7f60-8172-93a4b5c6d7e8', + ); + }); + it('ends the open runs when the Harness closes', async () => { const harness = harnessFor(generationPhase([{ run: { inputs: [5] } }])); let onClose: (() => void) | undefined; diff --git a/packages/server-utils/test/ai/lib/utils/workers-ai-utils.test.ts b/packages/server-utils/test/ai/lib/utils/workers-ai-utils.test.ts index 85d690e01421..fab753399663 100644 --- a/packages/server-utils/test/ai/lib/utils/workers-ai-utils.test.ts +++ b/packages/server-utils/test/ai/lib/utils/workers-ai-utils.test.ts @@ -69,6 +69,10 @@ describe('workers-ai utils', () => { expect(getOperationName('@cf/cloudflare/clef', { state: 'Help!', questions: {} })).toBe('decide'); }); + it('returns "extract" for Cloudflare Clairic models', () => { + expect(getOperationName('@cf/cloudflare/clairic', { state: 'Invoice INV-1', fields: {} })).toBe('extract'); + }); + it('does not return "decide" for other TypeSafe models', () => { expect(getOperationName('typesafe/other', { prompt: 'Hello' })).toBe('chat'); }); @@ -141,6 +145,34 @@ describe('workers-ai utils', () => { }); describe('addRequestAttributes', () => { + it('records extract requests without the model and image data', () => { + const { span, attributes } = createMockSpan(); + const fields = { total: { type: 'number', unit: 'USD' } }; + + addRequestAttributes( + span, + { + model: 'clairic', + state: 'Total: $10', + fields, + images: ['data:image/png;base64,AAAA', 'data:image/png;base64,BBBB'], + reference_date: '2026-10-21', + }, + 'extract', + ); + + expect(attributes).toEqual({ + [GEN_AI_INPUT_MESSAGES]: JSON.stringify([ + { + state: 'Total: $10', + fields, + images: ['[Blob substitute]', '[Blob substitute]'], + reference_date: '2026-10-21', + }, + ]), + }); + }); + it('records messages and extracts system instructions', () => { const { span, attributes } = createMockSpan(); @@ -226,6 +258,31 @@ describe('workers-ai utils', () => { }); }); + it('records extract responses without the model and usage', () => { + const { span, attributes } = createMockSpan(); + const answers = { total: { type: 'number', value: 10, confidence: 0.99 } }; + + addResponseAttributes( + span, + { + model: 'clairic', + answers, + truncated: false, + usage: { state_tokens: 5, input_tokens: 40, output_tokens: 3, passes: 1 }, + }, + true, + 'extract', + ); + + expect(attributes).toEqual({ + [GEN_AI_RESPONSE_MODEL]: 'clairic', + [GEN_AI_USAGE_INPUT_TOKENS]: 40, + [GEN_AI_USAGE_OUTPUT_TOKENS]: 3, + [GEN_AI_USAGE_TOTAL_TOKENS]: 43, + [GEN_AI_OUTPUT_MESSAGES]: JSON.stringify([{ answers, truncated: false }]), + }); + }); + it('sets token usage and computes the total from input and output tokens', () => { const { span, attributes } = createMockSpan(); diff --git a/packages/server-utils/test/ai/tracing/ai-integration-proxy.test.ts b/packages/server-utils/test/ai/tracing/ai-integration-proxy.test.ts new file mode 100644 index 000000000000..100870c7bbbc --- /dev/null +++ b/packages/server-utils/test/ai/tracing/ai-integration-proxy.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { instrumentGoogleGenAIClient } from '../../../src/ai/google-genai'; +import { instrumentOpenAiClient } from '../../../src/ai/openai'; + +interface MutableClient { + service: { execute: () => Promise } | null; + getVersion: (() => string) | null; +} + +const clientInstrumenters: Array<{ + provider: string; + instrument: (client: MutableClient) => MutableClient; +}> = [ + { provider: 'OpenAI', instrument: client => instrumentOpenAiClient(client) }, + { provider: 'Google GenAI', instrument: client => instrumentGoogleGenAIClient(client) }, +]; + +describe('AI integration deep proxies', () => { + it('returns stable references for OpenAI client properties', () => { + const client = { + chat: { completions: { create: () => Promise.resolve({}) } }, + getBaseURL: () => 'https://api.openai.com', + }; + + const instrumentedClient = instrumentOpenAiClient(client); + const chat = instrumentedClient.chat; + const completions = chat.completions; + const create = completions.create; + const getBaseURL = instrumentedClient.getBaseURL; + + expect(instrumentedClient.chat).toBe(chat); + expect(chat.completions).toBe(completions); + expect(completions.create).toBe(create); + expect(instrumentedClient.chat.completions.create).toBe(create); + expect(instrumentedClient.getBaseURL).toBe(getBaseURL); + }); + + it('returns stable references for Google GenAI client properties', () => { + const client = { + models: { generateContent: () => Promise.resolve({}) }, + chats: { create: () => ({ sendMessage: () => Promise.resolve({}) }) }, + getVersion: () => '1.0.0', + }; + + const instrumentedClient = instrumentGoogleGenAIClient(client); + const models = instrumentedClient.models; + const generateContent = models.generateContent; + const chats = instrumentedClient.chats; + const create = chats.create; + const chat = chats.create(); + const sendMessage = chat.sendMessage; + const getVersion = instrumentedClient.getVersion; + + expect(instrumentedClient.models).toBe(models); + expect(models.generateContent).toBe(generateContent); + expect(instrumentedClient.models.generateContent).toBe(generateContent); + expect(instrumentedClient.chats).toBe(chats); + expect(chats.create).toBe(create); + expect(instrumentedClient.chats.create).toBe(create); + expect(chat.sendMessage).toBe(sendMessage); + expect(instrumentedClient.getVersion).toBe(getVersion); + }); + + it.each(clientInstrumenters)('drops cached $provider wrappers after properties are cleared', ({ instrument }) => { + const service = { execute: () => Promise.resolve() }; + const getVersion = () => '1.0.0'; + const client: MutableClient = { service, getVersion }; + const instrumentedClient = instrument(client); + const cachedService = instrumentedClient.service; + const cachedGetVersion = instrumentedClient.getVersion; + + client.service = null; + client.getVersion = null; + expect(instrumentedClient.service).toBeNull(); + expect(instrumentedClient.getVersion).toBeNull(); + + client.service = service; + client.getVersion = getVersion; + expect(instrumentedClient.service).not.toBe(cachedService); + expect(instrumentedClient.getVersion).not.toBe(cachedGetVersion); + }); +}); diff --git a/packages/server-utils/test/integrations/hapi-utils.test.ts b/packages/server-utils/test/integrations/hapi-utils.test.ts index a0a7a43971fa..f8a606864a9f 100644 --- a/packages/server-utils/test/integrations/hapi-utils.test.ts +++ b/packages/server-utils/test/integrations/hapi-utils.test.ts @@ -1,10 +1,22 @@ -import { setCurrentClient } from '@sentry/core'; +import { + HAPI_EXT_TYPE, + HAPI_PLUGIN_NAME, + HAPI_TYPE, + HTTP_REQUEST_METHOD, + HTTP_ROUTE, + SENTRY_OP, + SENTRY_ORIGIN, + SERVER_EXT_TYPE, +} from '@sentry/conventions/attributes'; +import { ROUTER } from '@sentry/conventions/op'; +import type { Span } from '@sentry/core'; +import { setCurrentClient, spanToJSON, startSpan } from '@sentry/core'; import { afterEach, describe, expect, it } from 'vitest'; -import { getExtMetadata, getRouteMetadata } from '../../src/integrations/hapi/hapi-utils'; +import { getExtMetadata, getRouteMetadata, wrapRouteArguments } from '../../src/integrations/hapi/hapi-utils'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; describe('getRouteMetadata', () => { - const route = { path: '/users/{id}', method: 'get' } as any; + const route = { path: '/users/{id}', method: 'GET' }; afterEach(() => { setCurrentClient(undefined as unknown as TestClient); @@ -14,21 +26,31 @@ describe('getRouteMetadata', () => { expect(getRouteMetadata(route)).toEqual({ name: 'GET /users/{id}', attributes: { - 'http.route': '/users/{id}', - 'http.request.method': 'get', - 'hapi.type': 'router', + [HTTP_ROUTE]: '/users/{id}', + [HTTP_REQUEST_METHOD]: 'GET', + [HAPI_TYPE]: 'router', }, }); }); it('describes a plugin-registered route as a plugin layer', () => { - expect(getRouteMetadata(route, 'my-plugin')).toEqual({ + expect(getRouteMetadata({ ...route, pluginName: 'my-plugin' })).toEqual({ name: 'GET /users/{id}', attributes: { - 'http.route': '/users/{id}', - 'http.request.method': 'get', - 'hapi.type': 'plugin', - 'hapi.plugin.name': 'my-plugin', + [HTTP_ROUTE]: '/users/{id}', + [HTTP_REQUEST_METHOD]: 'GET', + [HAPI_TYPE]: 'plugin', + [HAPI_PLUGIN_NAME]: 'my-plugin', + }, + }); + }); + + it('names the span after the path alone when the request method is unknown', () => { + expect(getRouteMetadata({ path: '/users/{id}' })).toEqual({ + name: '/users/{id}', + attributes: { + [HTTP_ROUTE]: '/users/{id}', + [HAPI_TYPE]: 'router', }, }); }); @@ -44,17 +66,51 @@ describe('getRouteMetadata', () => { const client = new TestClient(getDefaultTestClientOptions({ traceLifecycle: 'stream' })); setCurrentClient(client); - expect(getRouteMetadata(route, 'my-plugin').name).toBe('/users/{id}'); + expect(getRouteMetadata({ ...route, pluginName: 'my-plugin' }).name).toBe('/users/{id}'); }); it('falls back to a static span name when the route has no path', () => { const client = new TestClient(getDefaultTestClientOptions({ traceLifecycle: 'stream' })); setCurrentClient(client); - const pathlessRoute = { path: '', method: 'get' } as any; + expect(getRouteMetadata({ path: '', method: 'GET' }).name).toBe('Router'); + expect(getRouteMetadata({ path: '', method: 'GET', pluginName: 'my-plugin' }).name).toBe('Request handler'); + }); +}); - expect(getRouteMetadata(pathlessRoute).name).toBe('Router'); - expect(getRouteMetadata(pathlessRoute, 'my-plugin').name).toBe('Request handler'); +describe('wrapRouteArguments', () => { + afterEach(() => { + setCurrentClient(undefined as unknown as TestClient); + }); + + it.each([ + { request: { method: 'post' }, name: 'POST /multi', method: 'POST' }, + { request: {}, name: '/multi', method: undefined }, + ])('names the router span $name for a multi-method route', ({ request, name, method }) => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); + setCurrentClient(client); + client.init(); + const endedSpans: Span[] = []; + client.on('spanEnd', span => endedSpans.push(span)); + const route = { + method: ['GET', 'POST'], + path: '/multi', + handler: (_request: unknown, _h: unknown): unknown => 'ok', + }; + wrapRouteArguments([route]); + + const response = startSpan({ name: 'incoming' }, () => route.handler(request, {})); + + expect(response).toBe('ok'); + const routerSpan = endedSpans.map(span => spanToJSON(span)).find(span => span.attributes[SENTRY_OP] === ROUTER); + expect(routerSpan?.name).toBe(name); + expect(routerSpan?.attributes).toEqual({ + [HTTP_ROUTE]: '/multi', + [HTTP_REQUEST_METHOD]: method, + [HAPI_TYPE]: 'router', + [SENTRY_OP]: ROUTER, + [SENTRY_ORIGIN]: 'auto.http.hapi', + }); }); }); @@ -62,7 +118,12 @@ describe('getExtMetadata', () => { it('names an extension by its point', () => { expect(getExtMetadata('onPreHandler')).toEqual({ name: 'ext - onPreHandler', - attributes: { 'server.ext.type': 'onPreHandler', 'hapi.type': 'server.ext' }, + attributes: { + [HAPI_EXT_TYPE]: 'onPreHandler', + // oxlint-disable-next-line typescript/no-deprecated + [SERVER_EXT_TYPE]: 'onPreHandler', + [HAPI_TYPE]: 'server.ext', + }, }); }); @@ -75,9 +136,11 @@ describe('getExtMetadata', () => { expect(getExtMetadata('onPreHandler', 'my-plugin')).toEqual({ name: 'my-plugin: ext - onPreHandler', attributes: { - 'server.ext.type': 'onPreHandler', - 'hapi.type': 'server.ext', - 'hapi.plugin.name': 'my-plugin', + [HAPI_EXT_TYPE]: 'onPreHandler', + // oxlint-disable-next-line typescript/no-deprecated + [SERVER_EXT_TYPE]: 'onPreHandler', + [HAPI_TYPE]: 'server.ext', + [HAPI_PLUGIN_NAME]: 'my-plugin', }, }); }); diff --git a/packages/server-utils/test/integrations/vercel-ai/decide.test.ts b/packages/server-utils/test/integrations/vercel-ai/decide.test.ts index ae668d64e338..8a225cab123b 100644 --- a/packages/server-utils/test/integrations/vercel-ai/decide.test.ts +++ b/packages/server-utils/test/integrations/vercel-ai/decide.test.ts @@ -29,6 +29,27 @@ describe('Vercel AI decide spans', () => { getMainCarrier().__SENTRY__ = undefined; }); + function recordedAnswers(provider: string, result: Message['result']): unknown { + const message = { + type: 'experimental_decide', + event: { + provider, + modelId: 'typesafe/jev-1.13', + state: 'I was charged twice and the app keeps crashing', + questions: {}, + recordInputs: true, + recordOutputs: true, + }, + result, + } satisfies Message; + + const span = createSpanFromMessage(message, {} as Parameters[1])!; + enrichSpanOnEnd(span, message, {} as Parameters[2]); + span.end(); + + return JSON.parse(spanToStaticSpanJSON(endedSpans[0]!).data?.[GEN_AI_OUTPUT_MESSAGES] as string)[0].answers; + } + // `ai` 7.0.128 renamed the channel event from `experimental_evaluate` to `experimental_decide`. it.each(['experimental_evaluate', 'experimental_decide'] as const)('creates a decide span for `%s`', type => { const message = { @@ -58,29 +79,80 @@ describe('Vercel AI decide spans', () => { }); it.each(['typesafe', 'openai'])('restores answer confidence from `providerMetadata.%s`', provider => { - const message = { - type: 'experimental_decide', - event: { - provider, - modelId: 'gpt-6-luna', - state: 'I was charged twice', - questions: { department: { type: 'choice', criteria: { billing: 'Charges', support: 'Other' } } }, - recordInputs: true, - recordOutputs: true, - }, - result: { - answers: { department: { type: 'choice', choice: 'billing' } }, - providerMetadata: { [provider]: { confidence: { department: 0.91 } } }, - }, - } satisfies Message; + const answers = recordedAnswers(provider, { + answers: { department: { type: 'choice', choice: 'billing' } }, + providerMetadata: { [provider]: { confidence: { department: 0.91 } } }, + }); - const span = createSpanFromMessage(message, {} as Parameters[1])!; - enrichSpanOnEnd(span, message, {} as Parameters[2]); - span.end(); + expect(answers).toEqual({ department: { type: 'choice', choice: 'billing', confidence: 0.91 } }); + }); - const json = spanToStaticSpanJSON(endedSpans[0]!); - expect(JSON.parse(json.data?.[GEN_AI_OUTPUT_MESSAGES] as string)).toEqual([ - { type: 'evaluation', answers: { department: { type: 'choice', choice: 'billing', confidence: 0.91 } } }, - ]); + describe('OpenRouter evaluation models', () => { + it('restores choice confidence from `providerMetadata.openrouter.answers`', () => { + const answers = recordedAnswers('openrouter', { + answers: { + department: { type: 'choice', choice: 'billing', probabilities: { billing: 0.82, technical: 0.18 } }, + wantsRefund: { type: 'boolean', probability: 0.97 }, + }, + providerMetadata: { + openrouter: { provider: 'TypeSafe', answers: { department: { confidence: 0.64 }, wantsRefund: {} } }, + }, + }); + + expect(answers).toEqual({ + department: { + type: 'choice', + choice: 'billing', + probabilities: { billing: 0.82, technical: 0.18 }, + confidence: 0.64, + }, + wantsRefund: { type: 'boolean', probability: 0.97 }, + }); + }); + + it('restores score confidence and legend from `providerMetadata.openrouter.answers`', () => { + const legend = { '0': 'Not urgent', '1': 'Handle today', '2': 'Page on-call' }; + + const answers = recordedAnswers('openrouter', { + answers: { urgency: { type: 'score', score: 1.8, probabilities: { '0': 0, '1': 0.2, '2': 0.8 } } }, + providerMetadata: { openrouter: { answers: { urgency: { confidence: 0.31, legend } } } }, + }); + + expect(answers).toEqual({ + urgency: { + type: 'score', + score: 1.8, + probabilities: { '0': 0, '1': 0.2, '2': 0.8 }, + confidence: 0.31, + legend, + }, + }); + }); + + it.each([ + ['no provider metadata', undefined], + ['no `answers` entry', { openrouter: { provider: 'TypeSafe', usage: { cost: 0.0004 } } }], + ['no entry for the question', { openrouter: { answers: { department: { confidence: 0.64 } } } }], + ])('leaves answers unchanged with %s', (_, providerMetadata) => { + const answers = recordedAnswers('openrouter', { + answers: { urgency: { type: 'score', score: 1.8 } }, + providerMetadata, + }); + + expect(answers).toEqual({ urgency: { type: 'score', score: 1.8 } }); + }); + + it('keeps confidence and legend already on the answer', () => { + const answers = recordedAnswers('openrouter', { + answers: { urgency: { type: 'score', score: 1.8, confidence: 0.9, legend: { '2': 'Page on-call' } } }, + providerMetadata: { + openrouter: { answers: { urgency: { confidence: 0.31, legend: { '2': 'Escalate' } } } }, + }, + }); + + expect(answers).toEqual({ + urgency: { type: 'score', score: 1.8, confidence: 0.9, legend: { '2': 'Page on-call' } }, + }); + }); }); }); diff --git a/packages/server-utils/test/orchestrion/externalRequire.test.ts b/packages/server-utils/test/orchestrion/externalRequire.test.ts index 73951a3b971e..18619295982e 100644 --- a/packages/server-utils/test/orchestrion/externalRequire.test.ts +++ b/packages/server-utils/test/orchestrion/externalRequire.test.ts @@ -168,6 +168,8 @@ describe('createExternalRequireResolver', () => { }); it('keeps a package external that is not installed', async () => { - await expect(resolveRequire(createExternalRequireResolver(['pg']), 'pg-types', pgFile)).resolves.toBeNull(); + await expect( + resolveRequire(createExternalRequireResolver(['pg']), 'not-installed-package', pgFile), + ).resolves.toBeNull(); }); }); diff --git a/packages/server-utils/test/orchestrion/firebase.test.ts b/packages/server-utils/test/orchestrion/firebase.test.ts index d7e4f4a0259b..d19caa9a9579 100644 --- a/packages/server-utils/test/orchestrion/firebase.test.ts +++ b/packages/server-utils/test/orchestrion/firebase.test.ts @@ -45,7 +45,7 @@ describe('startFirestoreSpan', () => { name: 'addDoc cities', attributes: expect.objectContaining({ 'sentry.op': 'db.query', - 'sentry.origin': 'auto.firebase.firestore', + 'sentry.origin': 'auto.db.firebase.firestore', 'db.operation.name': 'addDoc', 'db.collection.name': 'cities', 'db.namespace': '[DEFAULT]', @@ -111,7 +111,7 @@ describe('wrapFunctionsRegistration', () => { expect.objectContaining({ name: 'firebase.function.http.request', attributes: expect.objectContaining({ - 'sentry.origin': 'auto.firebase.functions', + 'sentry.origin': 'auto.function.firebase', 'sentry.op': 'function.gcp', 'faas.trigger': 'http.request', 'faas.provider': 'firebase', @@ -150,7 +150,7 @@ describe('wrapFunctionsRegistration', () => { expect(captureExceptionSpy).toHaveBeenCalledWith( error, expect.objectContaining({ - mechanism: expect.objectContaining({ type: 'auto.firebase.functions' }), + mechanism: expect.objectContaining({ type: 'auto.function.firebase' }), }), ); expect(span.end).toHaveBeenCalledTimes(1); diff --git a/packages/server-utils/test/orchestrion/instrumentation.test.ts b/packages/server-utils/test/orchestrion/instrumentation.test.ts index fce7ffc3bf26..07c61fd5bb7a 100644 --- a/packages/server-utils/test/orchestrion/instrumentation.test.ts +++ b/packages/server-utils/test/orchestrion/instrumentation.test.ts @@ -1,5 +1,5 @@ import type { AsyncContextStrategy } from '@sentry/core'; -import { GLOBAL_OBJ, setAsyncContextStrategy } from '@sentry/core'; +import { debug, GLOBAL_OBJ, setAsyncContextStrategy } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { invokeOrchestrionInstrumentation } from '../../src/orchestrion/instrumentation'; @@ -190,4 +190,43 @@ describe('invokeOrchestrionInstrumentation', () => { expect(callback).not.toHaveBeenCalled(); expect(client.listenerCount()).toBe(1); }); + + describe('on Cloudflare', () => { + beforeEach(() => { + vi.stubGlobal('navigator', { userAgent: 'Cloudflare-Workers' }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('warns once when the vite plugin did not run', () => { + const warn = vi.spyOn(debug, 'warn').mockImplementation(() => undefined); + + invokeOrchestrionInstrumentation(makeClient() as never, ['openai'], vi.fn(), []); + invokeOrchestrionInstrumentation(makeClient() as never, ['openai'], vi.fn(), []); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('`@sentry/cloudflare/vite` plugin did not run')); + }); + + it('does not warn when the vite plugin ran, even before any module loaded', () => { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { bundler: new Set() }; + const warn = vi.spyOn(debug, 'warn').mockImplementation(() => undefined); + + invokeOrchestrionInstrumentation(makeClient() as never, ['pg'], vi.fn(), []); + + expect(warn).not.toHaveBeenCalled(); + }); + }); + + it('does not warn about the Cloudflare plugin on other runtimes', () => { + const warn = vi.spyOn(debug, 'warn').mockImplementation(() => undefined); + + invokeOrchestrionInstrumentation(makeClient() as never, ['redis'], vi.fn(), []); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); }); diff --git a/packages/server-utils/test/utils/monitorStream.test.ts b/packages/server-utils/test/utils/monitorStream.test.ts new file mode 100644 index 000000000000..fee33b3cec75 --- /dev/null +++ b/packages/server-utils/test/utils/monitorStream.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest'; +import { monitorStream } from '../../src/utils/monitorStream'; + +const encode = (text: string): Uint8Array => new TextEncoder().encode(text); + +// Runs every pending promise job, for checks that something did not happen. +const flushPromiseJobs = (): Promise => new Promise(resolve => setImmediate(resolve)); + +describe('monitorStream', () => { + it('passes the chunks through and calls onDone when the source closes', async () => { + const onDone = vi.fn(); + const onError = vi.fn(); + const source = new ReadableStream({ + start(controller) { + controller.enqueue(encode('a')); + controller.enqueue(encode('b')); + controller.close(); + }, + }); + + const text = await new Response(monitorStream(source, onDone, onError)).text(); + + expect(text).toBe('ab'); + expect(onDone).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + }); + + it('reads from the source only when the consumer pulls', async () => { + let pulls = 0; + const source = new ReadableStream( + { + pull(controller) { + pulls++; + controller.enqueue(encode(String(pulls))); + }, + }, + { highWaterMark: 0 }, + ); + + const reader = monitorStream(source, vi.fn(), vi.fn()).getReader(); + await flushPromiseJobs(); + expect(pulls).toBe(0); + + await reader.read(); + await flushPromiseJobs(); + expect(pulls).toBe(1); + await reader.cancel(); + }); + + it('cancels the source when the consumer cancels, without reporting an error', async () => { + let sourceCancelReason: unknown; + const onDone = vi.fn(); + const onError = vi.fn(); + const source = new ReadableStream({ + start(controller) { + controller.enqueue(encode('first event')); + }, + cancel(reason) { + sourceCancelReason = reason; + }, + }); + const reader = monitorStream(source, onDone, onError).getReader(); + const reason = new Error('client disconnected'); + + await reader.read(); + await reader.cancel(reason); + await vi.waitFor(() => expect(onDone).toHaveBeenCalledTimes(1)); + + expect(sourceCancelReason).toBe(reason); + expect(onError).not.toHaveBeenCalled(); + }); + + it('reports a source error and calls onDone, without an unhandled rejection', async () => { + const unhandled = vi.fn(); + process.on('unhandledRejection', unhandled); + const onDone = vi.fn(); + const onError = vi.fn(); + const error = new Error('source failed'); + let controller: ReadableStreamDefaultController; + const source = new ReadableStream({ + start(value) { + controller = value; + }, + }); + + try { + const response = new Response(monitorStream(source, onDone, onError)); + const text = response.text(); + controller!.error(error); + + await expect(text).rejects.toBe(error); + await vi.waitFor(() => expect(onDone).toHaveBeenCalledTimes(1)); + await flushPromiseJobs(); + + expect(onError).toHaveBeenCalledWith(error); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + process.off('unhandledRejection', unhandled); + } + }); + + it('does not report an abort of the source, such as a client disconnect, as an error', async () => { + const onDone = vi.fn(); + const onError = vi.fn(); + const abort = new DOMException('The request has been cancelled.', 'AbortError'); + let controller: ReadableStreamDefaultController; + const source = new ReadableStream({ + start(value) { + controller = value; + }, + }); + + const text = new Response(monitorStream(source, onDone, onError)).text(); + controller!.error(abort); + + await expect(text).rejects.toBe(abort); + await vi.waitFor(() => expect(onDone).toHaveBeenCalledTimes(1)); + expect(onError).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server-utils/test/utils/wrapServerRequestHandler.test.ts b/packages/server-utils/test/utils/wrapServerRequestHandler.test.ts new file mode 100644 index 000000000000..ff8cf8703fd1 --- /dev/null +++ b/packages/server-utils/test/utils/wrapServerRequestHandler.test.ts @@ -0,0 +1,223 @@ +import { HTTP_ROUTE, SENTRY_SEGMENT_NAME_SOURCE, URL_PATH_PARAMETER_KEY_BASE } from '@sentry/conventions/attributes'; +import type { Event } from '@sentry/core'; +import { getIsolationScope, setCurrentClient, withIsolationScope } from '@sentry/core'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { setAsyncLocalStorageAsyncContextStrategy } from '../../src/async-context'; +import type { ServerRequestHandlerConfig } from '../../src/utils/wrapServerRequestHandler'; +import { wrapServerRequestHandler } from '../../src/utils/wrapServerRequestHandler'; +import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; + +const MECHANISM = { handled: false, type: 'auto.http.test' }; + +function setup( + options: ServerRequestHandlerConfig['options'] = {}, + extra: Partial = {}, +): { + events: Event[]; + transactions: Event[]; + serve: (request: Request, handler: () => R | Promise) => Promise; +} { + const events: Event[] = []; + const transactions: Event[] = []; + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + beforeSend: event => { + events.push(event); + return null; + }, + beforeSendTransaction: event => { + transactions.push(event); + return null; + }, + }), + ); + setCurrentClient(client); + client.init(); + + const serve = ( + request: Request, + handler: () => R | Promise, + ): Promise => + withIsolationScope(isolationScope => + wrapServerRequestHandler( + { request, client, isolationScope, options, origin: 'auto.http.test', mechanism: MECHANISM, ...extra }, + handler, + ), + ); + return { events, transactions, serve }; +} + +describe('wrapServerRequestHandler', () => { + beforeAll(() => { + setAsyncLocalStorageAsyncContextStrategy(); + }); + + it.each(['OPTIONS', 'HEAD'])( + 'attaches request data and captures errors for %s requests, without a span', + async method => { + const { events, transactions, serve } = setup(); + const error = new Error(`${method} failed`); + + await expect( + serve(new Request('https://example.com/cors', { method }), () => { + expect(getIsolationScope().getScopeData().sdkProcessingMetadata.normalizedRequest?.method).toBe(method); + throw error; + }), + ).rejects.toBe(error); + + expect(events).toHaveLength(1); + expect(events[0]?.exception?.values?.[0]?.mechanism).toEqual(MECHANISM); + expect(transactions).toHaveLength(0); + }, + ); + + it('skips the body when ignoreRequestBody throws', async () => { + const { serve } = setup({ + ignoreRequestBody: () => { + throw new Error('filter failed'); + }, + }); + let data: unknown = 'not read'; + + const response = await serve(new Request('https://example.com/login', { method: 'POST', body: 'secret' }), () => { + data = getIsolationScope().getScopeData().sdkProcessingMetadata.normalizedRequest?.data; + return new Response('ok'); + }); + + expect(await response.text()).toBe('ok'); + expect(data).toBeUndefined(); + }); + + it('calls onSpanEnd and ends the span when a streamed response finishes', async () => { + const calls: string[] = []; + const { transactions, serve } = setup({ + onSpanEnd: (span, _request, response) => { + calls.push('onSpanEnd'); + span.setAttribute('onSpanEnd.status', response.status); + }, + }); + let controller: ReadableStreamDefaultController; + const source = new ReadableStream({ + start(value) { + controller = value; + }, + }); + + const response = await serve( + new Request('https://example.com/events'), + () => new Response(source, { status: 201, headers: { 'content-type': 'text/event-stream' } }), + ); + expect(response.status).toBe(201); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + controller!.enqueue(new TextEncoder().encode('data: one\n\n')); + expect(calls).toEqual([]); + + controller!.close(); + expect(await response.text()).toBe('data: one\n\n'); + await vi.waitFor(() => expect(transactions).toHaveLength(1)); + + expect(calls).toEqual(['onSpanEnd']); + expect(transactions[0]?.contexts?.trace?.data?.['onSpanEnd.status']).toBe(201); + }); + + // Serves a streamed response whose source fails after the handler returns. + async function serveFailingStream( + serve: ReturnType['serve'], + error: unknown = new Error('stream interrupted'), + ): Promise { + let controller: ReadableStreamDefaultController; + const source = new ReadableStream({ + start(value) { + controller = value; + }, + }); + + const response = await serve( + new Request('https://example.com/events'), + () => new Response(source, { headers: { 'content-type': 'application/x-ndjson' } }), + ); + const text = response.text(); + controller!.error(error); + await expect(text).rejects.toBe(error); + } + + it('captures an error from a streamed response source and marks the span as failed', async () => { + const { events, transactions, serve } = setup({ spans: true }); + + await serveFailingStream(serve); + await vi.waitFor(() => expect(transactions).toHaveLength(1)); + + expect(events).toHaveLength(1); + expect(events[0]?.exception?.values?.[0]?.mechanism).toEqual(MECHANISM); + expect(transactions[0]?.contexts?.trace?.status).toBe('internal_error'); + }); + + it('captures an error from a streamed response source without a span', async () => { + const { events, transactions, serve } = setup({ spans: false }); + + await serveFailingStream(serve); + await vi.waitFor(() => expect(events).toHaveLength(1)); + + expect(events[0]?.exception?.values?.[0]?.mechanism).toEqual(MECHANISM); + expect(transactions).toHaveLength(0); + }); + + it('ends the span when the handler returns no response, as after a WebSocket upgrade', async () => { + const onSpanEnd = vi.fn(); + const { events, transactions, serve } = setup({ spans: true, onSpanEnd }); + + const response = await serve(new Request('https://example.com/socket'), () => undefined); + await vi.waitFor(() => expect(transactions).toHaveLength(1)); + + expect(response).toBeUndefined(); + expect(events).toHaveLength(0); + expect(transactions[0]?.contexts?.response).toBeUndefined(); + expect(onSpanEnd).not.toHaveBeenCalled(); + }); + + it('does not capture or fail the span when the client disconnects from a streamed response', async () => { + const { events, transactions, serve } = setup({ spans: true }); + + await serveFailingStream(serve, new DOMException('The request has been cancelled.', 'AbortError')); + await vi.waitFor(() => expect(transactions).toHaveLength(1)); + + expect(events).toHaveLength(0); + expect(transactions[0]?.contexts?.trace?.status).toBe('ok'); + }); + + it('names the span after the route and adds the runtime attributes', async () => { + const { transactions, serve } = setup( + {}, + { routeName: '/users/:id', attributes: { [`${URL_PATH_PARAMETER_KEY_BASE}.id`]: '42' } }, + ); + + const response = await serve(new Request('https://example.com/users/42'), () => new Response('ok')); + expect(await response.text()).toBe('ok'); + await vi.waitFor(() => expect(transactions).toHaveLength(1)); + + expect(transactions[0]?.transaction).toBe('GET /users/:id'); + expect(transactions[0]?.contexts?.trace?.data).toEqual( + expect.objectContaining({ + [HTTP_ROUTE]: '/users/:id', + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + [`${URL_PATH_PARAMETER_KEY_BASE}.id`]: '42', + }), + ); + }); + + it('keeps every Set-Cookie header of the response', async () => { + const { transactions, serve } = setup(); + const headers = new Headers(); + headers.append('set-cookie', 'first=1'); + headers.append('set-cookie', 'second=2'); + + const response = await serve(new Request('https://example.com/login'), () => new Response('ok', { headers })); + expect(await response.text()).toBe('ok'); + await vi.waitFor(() => expect(transactions).toHaveLength(1)); + + const setCookie = transactions[0]?.contexts?.trace?.data?.['http.response.header.set-cookie'] as string[]; + expect(setCookie.map(cookie => cookie.split('=')[0])).toEqual(['first', 'second']); + }); +}); diff --git a/packages/solid/README.md b/packages/solid/README.md index 384501e1887e..ecd8e9d9fff5 100644 --- a/packages/solid/README.md +++ b/packages/solid/README.md @@ -1,6 +1,10 @@

- Sentry + + + + Sentry +

diff --git a/packages/solid/package.json b/packages/solid/package.json index 5c2577f23ff8..65642c9199bf 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -56,7 +56,7 @@ "dependencies": { "@sentry/browser": "11.9.0", "@sentry/core": "11.9.0", - "@sentry/conventions": "^0.27.0" + "@sentry/conventions": "^0.29.0" }, "peerDependencies": { "@solidjs/router": ">=0.13.4 <2.0.0-0", @@ -72,6 +72,7 @@ } }, "devDependencies": { + "@sentry-internal/rollup-utils": "11.9.0", "@solidjs/router": "^1.0.0", "@solidjs/testing-library": "0.8.5", "@tanstack/solid-router": "^1.169.2", @@ -103,9 +104,6 @@ "test:watch": "vitest --watch", "yalc:publish": "yalc publish --push --sig" }, - "volta": { - "extends": "../../package.json" - }, "nx": { "targets": { "build:types": { diff --git a/packages/solidstart/README.md b/packages/solidstart/README.md index 591fa7209b1a..63bfc49ab55e 100644 --- a/packages/solidstart/README.md +++ b/packages/solidstart/README.md @@ -1,6 +1,10 @@

- Sentry + + + + Sentry +

diff --git a/packages/solidstart/package.json b/packages/solidstart/package.json index 2fe8147048c6..bd3bf459cb15 100644 --- a/packages/solidstart/package.json +++ b/packages/solidstart/package.json @@ -87,14 +87,20 @@ "@sentry/server-utils": "11.9.0", "@sentry/solid": "11.9.0", "@sentry/bundler-plugins": "11.9.0", - "@sentry/conventions": "0.27.0" + "@sentry/conventions": "0.29.0", + "h3": "^1.15.10" }, "devDependencies": { + "@sentry-internal/rollup-utils": "11.9.0", + "@sentry/browser": "11.9.0", "@solidjs/router": "^1.0.0", "@solidjs/start": "^1.3.2", "@solidjs/testing-library": "0.8.5", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.5.2", + "@types/node": "^20.19.0", + "nitropack": "^2.13.4", + "node-mock-http": "^1.0.4", "solid-js": "^1.9.11", "vinxi": "^0.5.11", "vite": "^8.3.1", @@ -122,9 +128,6 @@ "test:watch": "vitest --watch", "yalc:publish": "yalc publish --push --sig" }, - "volta": { - "extends": "../../package.json" - }, "nx": { "targets": { "build:types": { diff --git a/packages/solidstart/rollup.npm.config.mjs b/packages/solidstart/rollup.npm.config.mjs index d28b29896b10..994c3cbe8fa7 100644 --- a/packages/solidstart/rollup.npm.config.mjs +++ b/packages/solidstart/rollup.npm.config.mjs @@ -13,11 +13,12 @@ export default makeNPMConfigVariants( 'src/client/solidrouter.ts', 'src/server/solidrouter.ts', 'src/vite/index.ts', + 'src/nitro/tunnelPlugin.ts', ], // prevent this internal code from ending up in our built package (this doesn't happen automatically because // the name doesn't match an SDK dependency) packageSpecificConfig: { - external: ['solid-js/web', 'solid-js', '@sentry/solid', '@sentry/solid/solidrouter'], + external: ['solid-js/web', 'solid-js', '@sentry/solid', '@sentry/solid/solidrouter', 'h3'], output: { dynamicImportInCjs: true, }, diff --git a/packages/solidstart/src/client/sdk.ts b/packages/solidstart/src/client/sdk.ts index 06ee8f092094..7473f329d11c 100644 --- a/packages/solidstart/src/client/sdk.ts +++ b/packages/solidstart/src/client/sdk.ts @@ -1,6 +1,7 @@ import type { Client, Integration } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; import type { BrowserOptions } from '@sentry/solid'; +import { applyTunnelBasePath } from '../common/tunnel'; import { browserTracingIntegration, getDefaultIntegrations as getDefaultSolidIntegrations, @@ -19,6 +20,7 @@ export function init(options: BrowserOptions): Client | undefined { ...options, }; + applyTunnelBasePath(opts); applySdkMetadata(opts, 'solidstart', ['solidstart', 'solid']); return initSolidSDK(opts); diff --git a/packages/solidstart/src/common/tunnel.ts b/packages/solidstart/src/common/tunnel.ts new file mode 100644 index 000000000000..0f8761686010 --- /dev/null +++ b/packages/solidstart/src/common/tunnel.ts @@ -0,0 +1,17 @@ +import type { Options } from '@sentry/core'; +import { _INTERNAL_applyTunnelDefaults as applyTunnelDefaults } from '@sentry/core'; + +/** Set on the server by `withSentry` to the app's `server.baseURL`. */ +export const TUNNEL_BASE_PATH_GLOBAL = '__sentry_solidstart_base_path'; + +declare const __SENTRY_SOLIDSTART_TUNNEL_BASE_PATH__: string | undefined; + +/** + * Serves the browser's managed tunnel under the app's `server.baseURL`, which `withSentry` defines at build time. + * A `basePath` the user set wins. + */ +export function applyTunnelBasePath(options: Pick): void { + const basePath = + typeof __SENTRY_SOLIDSTART_TUNNEL_BASE_PATH__ !== 'undefined' ? __SENTRY_SOLIDSTART_TUNNEL_BASE_PATH__ : ''; + options.tunnel = applyTunnelDefaults(options.tunnel, { basePath }); +} diff --git a/packages/solidstart/src/config/types.ts b/packages/solidstart/src/config/types.ts index 14e814c78195..d9c13ae76ebb 100644 --- a/packages/solidstart/src/config/types.ts +++ b/packages/solidstart/src/config/types.ts @@ -9,6 +9,7 @@ export type RollupConfig = { export type SolidStartInlineConfig = NonNullable[0]>; export type SolidStartInlineServerConfig = { + plugins?: string[]; hooks?: { close?: () => unknown; 'rollup:before'?: (nitro: Nitro) => unknown; diff --git a/packages/solidstart/src/config/withSentry.ts b/packages/solidstart/src/config/withSentry.ts index e4b0f6d08b49..c7f9ef04d9b5 100644 --- a/packages/solidstart/src/config/withSentry.ts +++ b/packages/solidstart/src/config/withSentry.ts @@ -1,4 +1,5 @@ -import { debug } from '@sentry/core'; +import { fileURLToPath } from 'node:url'; +import { debug, GLOBAL_OBJ, _INTERNAL_normalizeTunnelBasePath as normalizeTunnelBasePath } from '@sentry/core'; import { INSTRUMENTED_MODULE_NAMES } from '@sentry/server-utils/orchestrion/config'; import { commonJSInteropOptions, @@ -6,6 +7,7 @@ import { sentryOrchestrionPlugin, } from '@sentry/server-utils/orchestrion/rollup'; import type { Nitro, NitroConfig } from 'nitropack'; +import { TUNNEL_BASE_PATH_GLOBAL } from '../common/tunnel'; import { addSentryPluginToVite } from '../vite/sentrySolidStartVite'; import type { SentrySolidStartPluginOptions } from '../vite/types'; import { @@ -46,11 +48,21 @@ export function withSentry( }; const server = (solidStartConfig.server || {}) as SolidStartInlineServerConfig; + // Requests reach the tunnel with `server.baseURL` still in front, so both sides serve it under that. + const tunnelBasePath = normalizeTunnelBasePath( + (server as SolidStartInlineServerConfig & { baseURL?: string }).baseURL, + ); + // vinxi's dev server runs Nitro in this process, so the tunnel plugin can read it from here in dev. + if (tunnelBasePath) { + (GLOBAL_OBJ as { [TUNNEL_BASE_PATH_GLOBAL]?: string })[TUNNEL_BASE_PATH_GLOBAL] = tunnelBasePath; + } + const viteConfig = solidStartConfig.vite; const vite = typeof viteConfig === 'function' - ? (...args: Parameters) => addSentryPluginToVite(viteConfig(...args), sentryPluginOptions) - : addSentryPluginToVite(viteConfig, sentryPluginOptions); + ? (...args: Parameters) => + addSentryPluginToVite(viteConfig(...args), sentryPluginOptions, tunnelBasePath) + : addSentryPluginToVite(viteConfig, sentryPluginOptions, tunnelBasePath); const addBuildTimeInstrumentation = sentryPluginOptions.buildTimeInstrumentation !== false; @@ -61,6 +73,14 @@ export function withSentry( // but at runtime it holds resolved plugin objects we can push onto, so narrow to our own shape. const sentryRollupConfig = rollupConfig as unknown as RollupConfig; + if (tunnelBasePath) { + // The built server runs in another process, so the tunnel plugin reads the base path from the bundle. + sentryRollupConfig.plugins.push({ + name: 'sentry-solidstart-tunnel-base-path', + banner: `globalThis.${TUNNEL_BASE_PATH_GLOBAL} = ${JSON.stringify(tunnelBasePath)};`, + }); + } + if (addBuildTimeInstrumentation) { sentryRollupConfig.plugins.push( sentryOrchestrionPlugin({ buildTimeInstrumentation: sentryPluginOptions.buildTimeInstrumentation }), @@ -116,6 +136,15 @@ export function withSentry( externals, commonJS, modules: [...existingModules, sentryNitroModule], + // Through `server.plugins`, since vinxi's dev server ignores plugins that Nitro modules add. + plugins: [...(server.plugins || []), getTunnelPluginPath()], }, }; } + +function getTunnelPluginPath(): string { + const url = new URL('../nitro/tunnelPlugin.js', import.meta.url); + + // `import.meta.url` is a file URL in the built package, but not in every test environment. + return url.protocol === 'file:' ? fileURLToPath(url) : url.pathname; +} diff --git a/packages/solidstart/src/nitro/tunnelPlugin.ts b/packages/solidstart/src/nitro/tunnelPlugin.ts new file mode 100644 index 000000000000..c6882b74082d --- /dev/null +++ b/packages/solidstart/src/nitro/tunnelPlugin.ts @@ -0,0 +1,37 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, + _INTERNAL_isServedTunnelRequest as isServedTunnelRequest, + _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, +} from '@sentry/core/server'; +import { eventHandler, toWebRequest } from 'h3'; +import type { NitroAppPlugin } from 'nitropack'; +import { TUNNEL_BASE_PATH_GLOBAL } from '../common/tunnel'; + +/** + * Answers managed tunnel requests on SolidStart v1 (Nitro v2), from the first layer of the h3 app. That runs before + * route rules, the app's middleware and routing. `withSentry` registers it. + */ +export default (nitroApp => { + // The first layer sees the path before Nitro strips `server.baseURL`, so the tunnel is served under it. The server + // bundle's banner sets the global before this plugin runs. + setManagedTunnelDefaults({ + basePath: (GLOBAL_OBJ as { [TUNNEL_BASE_PATH_GLOBAL]?: string })[TUNNEL_BASE_PATH_GLOBAL], + }); + + // Not the `request` hook: h3 still runs the first layer after a hook answered, so route rules would act on the + // sent response. + nitroApp.h3App.stack.unshift({ + route: '', + handler: eventHandler(event => { + // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. + if (!isServedTunnelRequest({ method: event.method, url: event.path })) { + return undefined; + } + + // `toWebRequest` also reads the body of the mock requests serverless presets pass through `localFetch`, which + // aren't Node streams. A returned `Response` is sent and ends the request, `undefined` hands it on. + return handleServedTunnelRequest(toWebRequest(event)); + }), + }); +}) satisfies NitroAppPlugin; diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index 0e7cec54fa4f..b2d128847df8 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -126,6 +126,11 @@ export { startSpanManual, tediousIntegration, trpcMiddleware, + serveTunnelIntegration, + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, updateSpanName, withActiveSpan, withIsolationScope, diff --git a/packages/solidstart/src/vite/sentrySolidStartVite.ts b/packages/solidstart/src/vite/sentrySolidStartVite.ts index 8bb0a55741d1..61b884c29d2e 100644 --- a/packages/solidstart/src/vite/sentrySolidStartVite.ts +++ b/packages/solidstart/src/vite/sentrySolidStartVite.ts @@ -7,10 +7,22 @@ import type { SentrySolidStartPluginOptions } from './types'; /** * Various Sentry vite plugins to be used for SolidStart. */ -export const sentrySolidStartVite = (options: SentrySolidStartPluginOptions = {}, viteConfig: UserConfig): Plugin[] => { +export const sentrySolidStartVite = ( + options: SentrySolidStartPluginOptions = {}, + viteConfig: UserConfig, + tunnelBasePath = '', +): Plugin[] => { warnOnRemovedBuildOptions(options, ['unstable_sentryVitePluginOptions']); - const sentryPlugins: Plugin[] = []; + const sentryPlugins: Plugin[] = [ + { + name: 'sentry-solidstart-tunnel-base-path', + // Serves the browser's managed tunnel under `server.baseURL`, like every other route of the app. + config: () => ({ + define: { __SENTRY_SOLIDSTART_TUNNEL_BASE_PATH__: JSON.stringify(tunnelBasePath) }, + }), + }, + ]; if (options.autoInjectServerSentry !== 'experimental_dynamic-import') { sentryPlugins.push(makeBuildInstrumentationFilePlugin(options)); @@ -31,9 +43,13 @@ export const sentrySolidStartVite = (options: SentrySolidStartPluginOptions = {} /** * Helper to add the Sentry SolidStart vite plugin to a vite config. */ -export const addSentryPluginToVite = (config: UserConfig = {}, options: SentrySolidStartPluginOptions): UserConfig => { +export const addSentryPluginToVite = ( + config: UserConfig = {}, + options: SentrySolidStartPluginOptions, + tunnelBasePath?: string, +): UserConfig => { const plugins = Array.isArray(config.plugins) ? [...config.plugins] : []; - plugins.unshift(sentrySolidStartVite(options, config)); + plugins.unshift(sentrySolidStartVite(options, config, tunnelBasePath)); return { ...config, diff --git a/packages/solidstart/test/common/tunnel.test.ts b/packages/solidstart/test/common/tunnel.test.ts new file mode 100644 index 000000000000..25263d12d950 --- /dev/null +++ b/packages/solidstart/test/common/tunnel.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { applyTunnelBasePath } from '../../src/common/tunnel'; + +describe('applyTunnelBasePath', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('serves the managed tunnel under the defined base path', () => { + vi.stubGlobal('__SENTRY_SOLIDSTART_TUNNEL_BASE_PATH__', '/app'); + const options: Parameters[0] = { tunnel: true }; + + applyTunnelBasePath(options); + + expect(options.tunnel).toEqual({ basePath: '/app' }); + }); + + it('leaves the tunnel alone without a base path', () => { + const options: Parameters[0] = { tunnel: true }; + + applyTunnelBasePath(options); + + expect(options.tunnel).toBe(true); + }); +}); diff --git a/packages/solidstart/test/config/withSentry.test.ts b/packages/solidstart/test/config/withSentry.test.ts index 3afba804811d..c4c056319b55 100644 --- a/packages/solidstart/test/config/withSentry.test.ts +++ b/packages/solidstart/test/config/withSentry.test.ts @@ -78,6 +78,16 @@ describe('withSentry()', () => { expect(addInstrumentationFileToBuildMock).toHaveBeenCalledWith(nitroOptions); }); + it('registers the managed tunnel plugin through server.plugins and keeps user plugins', () => { + const config = withSentry({ server: { plugins: ['./user-plugin.ts'] } }, {}); + + expect((config?.server as { plugins?: string[] }).plugins).toEqual([ + './user-plugin.ts', + // Vitest serves the source file, the built package the transpiled one. + expect.stringMatching(/\/nitro\/tunnelPlugin\.[jt]s$/), + ]); + }); + it('does not override user-defined hooks in server.hooks', () => { const config = withSentry(solidStartConfig, {}); expect(config?.server.hooks?.['rollup:before']).toBe(userDefinedNitroRollupBeforeHookMock); @@ -116,6 +126,7 @@ describe('withSentry()', () => { }); const names = config?.vite.plugins.flat().map((plugin: Plugin) => plugin.name); expect(names).toEqual([ + 'sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file', 'sentry-vite-plugin', 'sentry-solidstart-update-source-map-setting', @@ -139,6 +150,7 @@ describe('withSentry()', () => { const names = config?.vite.plugins.flat().map((plugin: Plugin) => plugin.name); expect(names).toEqual([ + 'sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file', 'sentry-vite-plugin', 'sentry-solidstart-update-source-map-setting', @@ -166,6 +178,7 @@ describe('withSentry()', () => { .plugins.flat() .map((plugin: Plugin) => plugin.name); expect(names).toEqual([ + 'sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file', 'sentry-vite-plugin', 'sentry-solidstart-update-source-map-setting', diff --git a/packages/solidstart/test/nitro/tunnelPlugin.test.ts b/packages/solidstart/test/nitro/tunnelPlugin.test.ts new file mode 100644 index 000000000000..0f03cef43745 --- /dev/null +++ b/packages/solidstart/test/nitro/tunnelPlugin.test.ts @@ -0,0 +1,90 @@ +import type { Client } from '@sentry/core'; +import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; +import { _INTERNAL_resetManagedTunnelDefaults, serveTunnelIntegration } from '@sentry/core/server'; +import { createApp, eventHandler, setHeaders, toNodeListener } from 'h3'; +import { fetchNodeRequestHandler } from 'node-mock-http'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import tunnelPlugin from '../../src/nitro/tunnelPlugin'; + +const DSN = 'https://public@o1.ingest.sentry.io/1337'; + +type RequestHook = (event: unknown) => Promise; + +/** + * Calls an h3 app wired like Nitro v2's: the `request` hook runs in `onRequest`, and the route rules layer, here + * with a `headers` rule, comes before the app. Requests come in the way serverless presets pass them through + * `localFetch`, with node-mock-http's request, which isn't a Node stream. + */ +function fetchApp(url: string, init: RequestInit): Promise { + const requestHooks: RequestHook[] = []; + const app = createApp({ + onRequest: async event => { + for (const hook of requestHooks) { + await hook(event); + } + }, + onError: error => onError(error), + }); + app.use(eventHandler(event => setHeaders(event, { 'x-route-rule': '1' }))); + app.use(eventHandler(() => 'app')); + (tunnelPlugin as (nitroApp: unknown) => void)({ + h3App: app, + hooks: { hook: (name: string, fn: RequestHook) => name === 'request' && requestHooks.push(fn) }, + }); + + return fetchNodeRequestHandler(toNodeListener(app), `http://localhost${url}`, init); +} + +const onError = vi.fn(); + +describe('SolidStart v1 tunnel plugin', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + _INTERNAL_resetManagedTunnelDefaults(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('answers tunnel requests before route rules and the app with `serveTunnelIntegration`', async () => { + const options = { dsn: DSN }; + const client = { getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client; + getCurrentScope().setClient(client); + serveTunnelIntegration().setup!(client); + const sentryFetch = vi.fn((_url: string, _init: RequestInit) => Promise.resolve(new Response('forwarded'))); + vi.stubGlobal('fetch', sentryFetch); + const envelope = serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as string; + + const response = await fetchApp(getTunnelPath(DSN)!, { + method: 'POST', + body: envelope, + headers: { 'content-length': String(envelope.length) }, + }); + + expect(await response.text()).toBe('forwarded'); + expect(await new Response(sentryFetch.mock.lastCall![1].body).text()).toBe(envelope); + // A route rule that ran after the answer would fail to set headers on the sent response. + expect(response.headers.get('x-route-rule')).toBeNull(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('serves no tunnel without `serveTunnelIntegration`', async () => { + const options = { dsn: DSN }; + getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client); + const envelope = serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as string; + + const response = await fetchApp(getTunnelPath(DSN)!, { + method: 'POST', + body: envelope, + headers: { 'content-length': String(envelope.length) }, + }); + + expect(await response.text()).toBe('app'); + }); + + it('leaves every other request to the app', async () => { + const response = await fetchApp('/api/users', { method: 'POST', body: 'x' }); + + expect(await response.text()).toBe('app'); + expect(response.headers.get('x-route-rule')).toBe('1'); + }); +}); diff --git a/packages/solidstart/test/vite/sentrySolidStartVite.test.ts b/packages/solidstart/test/vite/sentrySolidStartVite.test.ts index 545638f9e3dc..db2382efdc45 100644 --- a/packages/solidstart/test/vite/sentrySolidStartVite.test.ts +++ b/packages/solidstart/test/vite/sentrySolidStartVite.test.ts @@ -26,6 +26,7 @@ describe('sentrySolidStartVite()', () => { const plugins = getSentrySolidStartVitePlugins(); const names = plugins.map(plugin => plugin.name); expect(names).toEqual([ + 'sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file', 'sentry-vite-plugin', 'sentry-solidstart-update-source-map-setting', @@ -35,7 +36,7 @@ describe('sentrySolidStartVite()', () => { it("returns only build-instrumentation-file plugin if source maps upload isn't enabled", () => { const plugins = getSentrySolidStartVitePlugins({ sourcemaps: { disable: true } }); const names = plugins.map(plugin => plugin.name); - expect(names).toEqual(['sentry-solidstart-build-instrumentation-file']); + expect(names).toEqual(['sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file']); }); it('returns only build-instrumentation-file plugin if `NODE_ENV` is development', async () => { @@ -44,7 +45,7 @@ describe('sentrySolidStartVite()', () => { const plugins = getSentrySolidStartVitePlugins({ sourcemaps: { disable: false } }); const names = plugins.map(plugin => plugin.name); - expect(names).toEqual(['sentry-solidstart-build-instrumentation-file']); + expect(names).toEqual(['sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file']); process.env.NODE_ENV = previousEnv; }); diff --git a/packages/svelte/README.md b/packages/svelte/README.md index f46a8f834f07..e164dea00903 100644 --- a/packages/svelte/README.md +++ b/packages/svelte/README.md @@ -1,6 +1,10 @@

- Sentry + + + + Sentry +

diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 632cc6f6e7c8..f9f59c288044 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -33,7 +33,7 @@ }, "dependencies": { "@sentry/browser": "11.9.0", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/core": "11.9.0", "magic-string": "~0.30.0" }, @@ -41,6 +41,7 @@ "svelte": "3.x || 4.x || 5.x" }, "devDependencies": { + "@sentry-internal/rollup-utils": "11.9.0", "@sveltejs/vite-plugin-svelte": "1.4.0", "@testing-library/svelte": "^3.2.1", "svelte": "3.59.2", @@ -65,8 +66,5 @@ "test:watch": "vitest --watch", "yalc:publish": "yalc publish --push --sig" }, - "volta": { - "extends": "../../package.json" - }, "sideEffects": false } diff --git a/packages/sveltekit/README.md b/packages/sveltekit/README.md index 5a8fa50ff780..5b22c150ac10 100644 --- a/packages/sveltekit/README.md +++ b/packages/sveltekit/README.md @@ -1,6 +1,10 @@

- Sentry + + + + Sentry +

diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index 2687bd3a7a9b..f5bf6beae848 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -73,7 +73,7 @@ "@opentelemetry/api": "^1.9.1", "@sentry/cloudflare": "11.9.0", "@sentry/core": "11.9.0", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/node": "11.9.0", "@sentry/server-runtime-injection": "11.9.0", "@sentry/server-utils": "11.9.0", @@ -85,8 +85,11 @@ "sorcery": "1.0.0" }, "devDependencies": { + "@sentry-internal/rollup-utils": "11.9.0", + "@sentry/browser": "11.9.0", "@sveltejs/kit": "^2.70.2", "@sveltejs/vite-plugin-svelte": "^3.0.0", + "@types/node": "^20.19.0", "svelte": "^4.2.8", "vite": "^8.3.1" }, @@ -110,8 +113,5 @@ "test:watch": "vitest --watch", "yalc:publish": "yalc publish --push --sig" }, - "volta": { - "extends": "../../package.json" - }, "sideEffects": false } diff --git a/packages/sveltekit/src/client/sdk.ts b/packages/sveltekit/src/client/sdk.ts index a6294ad25977..4c1dc3c2edc6 100644 --- a/packages/sveltekit/src/client/sdk.ts +++ b/packages/sveltekit/src/client/sdk.ts @@ -2,6 +2,7 @@ import type { Client, Integration } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; import type { BrowserOptions } from '@sentry/svelte'; import { getDefaultIntegrations as getDefaultSvelteIntegrations, init as initSvelteSdk, WINDOW } from '@sentry/svelte'; +import { applyTunnelBasePath } from '../common/tunnel'; import { browserTracingIntegration as svelteKitBrowserTracingIntegration } from './browserTracingIntegration'; type WindowWithSentryFetchProxy = typeof WINDOW & { @@ -23,6 +24,7 @@ export function init(options: BrowserOptions): Client | undefined { }; applySdkMetadata(opts, 'sveltekit', ['sveltekit', 'svelte']); + applyTunnelBasePath(opts); // 1. Switch window.fetch to our fetch proxy we injected earlier const actualFetch = switchToFetchProxy(); diff --git a/packages/sveltekit/src/common/tunnel.ts b/packages/sveltekit/src/common/tunnel.ts new file mode 100644 index 000000000000..f94fb9878001 --- /dev/null +++ b/packages/sveltekit/src/common/tunnel.ts @@ -0,0 +1,20 @@ +import type { Options } from '@sentry/core'; +import { _INTERNAL_applyTunnelDefaults as applyTunnelDefaults, GLOBAL_OBJ } from '@sentry/core'; + +/** Set by `sentrySvelteKit()` to the app's `paths.base`. */ +export const TUNNEL_BASE_PATH_GLOBAL = '__sentry_sveltekit_base_path'; + +/** + * Returns the app's `paths.base`, once a module `sentrySvelteKit()` prepends it to has loaded. + */ +export function getTunnelBasePath(): string | undefined { + return (GLOBAL_OBJ as { [TUNNEL_BASE_PATH_GLOBAL]?: string })[TUNNEL_BASE_PATH_GLOBAL]; +} + +/** + * Sends to the managed tunnel under the app's base path, which SvelteKit requires for every request it handles. + * A `basePath` the user set wins. + */ +export function applyTunnelBasePath(options: Pick): void { + options.tunnel = applyTunnelDefaults(options.tunnel, { basePath: getTunnelBasePath() }); +} diff --git a/packages/sveltekit/src/server-common/handle.ts b/packages/sveltekit/src/server-common/handle.ts index afbd7c9ba4a2..a910add9b0d1 100644 --- a/packages/sveltekit/src/server-common/handle.ts +++ b/packages/sveltekit/src/server-common/handle.ts @@ -19,7 +19,12 @@ import { withIsolationScope, filterCollectedUrl, } from '@sentry/core'; -import { flushIfServerless } from '@sentry/core/server'; +import { + flushIfServerless, + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, + _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, +} from '@sentry/core/server'; +import { getTunnelBasePath } from '../common/tunnel'; import type { Handle, ResolveOptions } from '@sveltejs/kit'; import { DEBUG_BUILD } from '../common/debug-build'; import { getTracePropagationData, sendErrorToSentry } from './utils'; @@ -284,6 +289,10 @@ async function instrumentHandle( * ``` */ export function sentryHandle(handlerOptions?: SentryHandleOptions): Handle { + // The hooks file creates this after the `sentrySvelteKit()` base path module ran, however the SDK was started + // (`--import`, a custom server), and later per-request clients (Cloudflare) start with it too. + setManagedTunnelDefaults({ basePath: getTunnelBasePath() }); + const { handleUnknownRoutes, ...rest } = handlerOptions ?? {}; const options = { handleUnknownRoutes: handleUnknownRoutes ?? false, @@ -291,6 +300,11 @@ export function sentryHandle(handlerOptions?: SentryHandleOptions): Handle { }; const sentryRequestHandler: Handle = input => { + const tunnelResponse = handleServedTunnelRequest(input.event.request); + if (tunnelResponse) { + return tunnelResponse; + } + const backwardsForwardsCompatibleEvent = input.event as typeof input.event & BackwardsForwardsCompatibleEvent; // Escape hatch to suppress request isolation and trace continuation (see initCloudflareSentryHandle) diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index 55c0f48ba010..270abc2ae43c 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -126,6 +126,11 @@ export { systemErrorIntegration, tediousIntegration, trpcMiddleware, + serveTunnelIntegration, + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, updateSpanName, withActiveSpan, withIsolationScope, diff --git a/packages/sveltekit/src/vite/injectGlobalValues.ts b/packages/sveltekit/src/vite/injectGlobalValues.ts index 25b419779b97..cbe75dcb772e 100644 --- a/packages/sveltekit/src/vite/injectGlobalValues.ts +++ b/packages/sveltekit/src/vite/injectGlobalValues.ts @@ -1,8 +1,8 @@ -import { escapeStringForRegex, type InternalGlobal } from '@sentry/core'; +import { type InternalGlobal } from '@sentry/core'; import MagicString from 'magic-string'; import type { Plugin } from 'vite'; import type { ResolvedKitConfig } from './kitConfig'; -import { getHooksFileName } from './svelteConfig'; +import { getHooksFileName, makeEntryFileMatcher } from './svelteConfig'; export type GlobalSentryValues = { __sentry_sveltekit_output_dir?: string; @@ -47,9 +47,14 @@ export function makeGlobalValuesInjectionPlugin(options: GlobalValuesInjectionOp // The SvelteKit config is only available once Vite has resolved its plugins, so we compute // the injected values lazily (but only once) instead of at plugin creation time. - let injectionValuesPromise: Promise<{ globalSentryValues: GlobalSentryValues; hooksFileRegexp: RegExp }> | undefined; - - const getInjectionValues = (): Promise<{ globalSentryValues: GlobalSentryValues; hooksFileRegexp: RegExp }> => + let injectionValuesPromise: + | Promise<{ globalSentryValues: GlobalSentryValues; isServerEntryFile: (id: string) => boolean }> + | undefined; + + const getInjectionValues = (): Promise<{ + globalSentryValues: GlobalSentryValues; + isServerEntryFile: (id: string) => boolean; + }> => (injectionValuesPromise ??= (async () => { const kitConfig = await getKitConfig(); @@ -67,8 +72,7 @@ export function makeGlobalValuesInjectionPlugin(options: GlobalValuesInjectionOp return { globalSentryValues, - // oxlint-disable-next-line sdk/no-regexp-constructor -- not end user input + escaped anyway - hooksFileRegexp: new RegExp(`/${escapeStringForRegex(serverHooksFile)}(.(js|ts|mjs|mts))?`), + isServerEntryFile: makeEntryFileMatcher(kitConfig, [serverHooksFile, 'src/instrumentation.server']), }; })()); @@ -103,11 +107,9 @@ export function makeGlobalValuesInjectionPlugin(options: GlobalValuesInjectionOp }, transform: async (code, id) => { - const { hooksFileRegexp } = await getInjectionValues(); - - const isServerEntryFile = /instrumentation\.server\./.test(id) || hooksFileRegexp.test(id); + const { isServerEntryFile } = await getInjectionValues(); - if (isServerEntryFile) { + if (isServerEntryFile(id)) { if (debug) { // eslint-disable-next-line no-console console.log('[Global Values Plugin] Injecting global values into', id); diff --git a/packages/sveltekit/src/vite/kitConfig.ts b/packages/sveltekit/src/vite/kitConfig.ts index a8fc51e654d6..41cb34eb3303 100644 --- a/packages/sveltekit/src/vite/kitConfig.ts +++ b/packages/sveltekit/src/vite/kitConfig.ts @@ -15,6 +15,8 @@ import { loadSvelteConfig } from './svelteConfig'; export type ResolvedKitConfig = { adapter?: Adapter; outDir?: string; + /** File extensions SvelteKit treats as modules, `['.js', '.ts']` by default. */ + moduleExtensions?: string[]; files?: { hooks?: { client?: string; diff --git a/packages/sveltekit/src/vite/sentryVitePlugins.ts b/packages/sveltekit/src/vite/sentryVitePlugins.ts index d4bf6ead91ec..b0fe5739d283 100644 --- a/packages/sveltekit/src/vite/sentryVitePlugins.ts +++ b/packages/sveltekit/src/vite/sentryVitePlugins.ts @@ -11,6 +11,7 @@ import { makeGlobalValuesInjectionPlugin } from './injectGlobalValues'; import { createKitConfigResolver } from './kitConfig'; import { makeCustomSentryVitePlugins } from './sourceMaps'; import { getAdapterOutputDir } from './svelteConfig'; +import { makeTunnelBasePathPlugin } from './tunnelBasePath'; import type { CustomSentryVitePluginOptions, SentrySvelteKitPluginOptions } from './types'; const DEFAULT_PLUGIN_OPTIONS: SentrySvelteKitPluginOptions = { @@ -56,6 +57,7 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {} kitConfigResolver.plugin, makeBrowserTracingVariantResolverPlugin(), makeOpenTelemetryApiResolverPlugin(), + makeTunnelBasePathPlugin(getKitConfig), ]; if (mergedOptions.autoInstrument) { diff --git a/packages/sveltekit/src/vite/svelteConfig.ts b/packages/sveltekit/src/vite/svelteConfig.ts index 7151ba053f4c..0d90173b8dbd 100644 --- a/packages/sveltekit/src/vite/svelteConfig.ts +++ b/packages/sveltekit/src/vite/svelteConfig.ts @@ -1,3 +1,4 @@ +import { escapeStringForRegex } from '@sentry/core'; import type { Builder } from '@sveltejs/kit'; import * as fs from 'fs'; import * as os from 'os'; @@ -54,6 +55,23 @@ export function getHooksFileName(kitConfig: ResolvedKitConfig, hookType: 'client return kitConfig.files?.hooks?.[hookType] || `src/hooks.${hookType}`; } +/** + * Matches the Vite module ids of SvelteKit entry files given without an extension, like the hooks files: the file with + * one of SvelteKit's module extensions, or the `index` file of a directory by that name, which SvelteKit also accepts. + */ +export function makeEntryFileMatcher(kitConfig: ResolvedKitConfig, entryPaths: string[]): (id: string) => boolean { + const extensions = new Set([...(kitConfig.moduleExtensions ?? ['.js', '.ts']), '.mjs', '.mts']); + const extensionPattern = [...extensions] + .map(extension => escapeStringForRegex(extension.replace(/^\./, ''))) + .join('|'); + const pathPattern = entryPaths.map(escapeStringForRegex).join('|'); + // oxlint-disable-next-line sdk/no-regexp-constructor -- not end user input + escaped anyway + const entryFileRegexp = new RegExp(`/(?:${pathPattern})(?:/index)?\\.(?:${extensionPattern})$`); + + // Vite appends queries like `?v=123` to module ids. + return id => entryFileRegexp.test(id.split('?')[0] ?? id); +} + /** * Attempts to read a custom output directory that can be specified in the options * of a SvelteKit adapter. If no custom output directory is specified, the default diff --git a/packages/sveltekit/src/vite/tunnelBasePath.ts b/packages/sveltekit/src/vite/tunnelBasePath.ts new file mode 100644 index 000000000000..a8e102905232 --- /dev/null +++ b/packages/sveltekit/src/vite/tunnelBasePath.ts @@ -0,0 +1,49 @@ +import MagicString from 'magic-string'; +import type { Plugin } from 'vite'; +import { TUNNEL_BASE_PATH_GLOBAL } from '../common/tunnel'; +import type { ResolvedKitConfig } from './kitConfig'; +import { getHooksFileName, makeEntryFileMatcher } from './svelteConfig'; + +const VIRTUAL_BASE_PATH_FILE = '\0sentry-sveltekit-tunnel-base-path'; + +/** + * Hands the app's `paths.base` to the client and server SDKs so `tunnel: true` works under a base path without + * the user repeating it. It's imported first in the hooks files, so it's set before any `init` they import. + */ +export function makeTunnelBasePathPlugin(getKitConfig: () => Promise): Plugin { + let basePath = ''; + let isEntryFile: ((id: string) => boolean) | undefined; + + return { + name: 'sentry-sveltekit-tunnel-base-path', + + configResolved: async () => { + const kitConfig = await getKitConfig(); + basePath = kitConfig.paths?.base || ''; + + isEntryFile = makeEntryFileMatcher(kitConfig, [ + getHooksFileName(kitConfig, 'server'), + getHooksFileName(kitConfig, 'client'), + 'src/instrumentation.server', + ]); + }, + + resolveId: id => (id === VIRTUAL_BASE_PATH_FILE ? { id, moduleSideEffects: true } : null), + + load: id => + id === VIRTUAL_BASE_PATH_FILE + ? `globalThis[${JSON.stringify(TUNNEL_BASE_PATH_GLOBAL)}] = ${JSON.stringify(basePath)};` + : null, + + transform: (code, id) => { + if (!basePath || !isEntryFile?.(id)) { + return null; + } + + const ms = new MagicString(code); + ms.prepend(`import ${JSON.stringify(VIRTUAL_BASE_PATH_FILE)};\n`); + + return { code: ms.toString(), map: ms.generateMap({ hires: true }) }; + }, + }; +} diff --git a/packages/sveltekit/src/worker/cloudflare.ts b/packages/sveltekit/src/worker/cloudflare.ts index 651cc6af6f37..f76450d64712 100644 --- a/packages/sveltekit/src/worker/cloudflare.ts +++ b/packages/sveltekit/src/worker/cloudflare.ts @@ -23,6 +23,7 @@ export function initCloudflareSentryHandle(options: CloudflareOptions): Handle { rewriteFramesIntegration(), svelteKitSpansIntegration(), ], + _extendsDefaultIntegrations: options.defaultIntegrations === undefined, // SvelteKit emits its own OpenTelemetry spans (Kit tracing), so, like the Node SvelteKit SDK, it // defaults to registering the tracer provider instead of inheriting Cloudflare's no-provider default. // Only the `init`-backed wrapper from the main entry point honors this; `@sentry/cloudflare/request` diff --git a/packages/sveltekit/src/worker/index.ts b/packages/sveltekit/src/worker/index.ts index 1ab26de90d38..850fa3c8763e 100644 --- a/packages/sveltekit/src/worker/index.ts +++ b/packages/sveltekit/src/worker/index.ts @@ -81,6 +81,11 @@ export { startSpan, startSpanManual, trpcMiddleware, + serveTunnelIntegration, + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, withActiveSpan, withIsolationScope, withMonitor, diff --git a/packages/sveltekit/test/common/tunnel.test.ts b/packages/sveltekit/test/common/tunnel.test.ts new file mode 100644 index 000000000000..d091d493b383 --- /dev/null +++ b/packages/sveltekit/test/common/tunnel.test.ts @@ -0,0 +1,28 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { applyTunnelBasePath, TUNNEL_BASE_PATH_GLOBAL } from '../../src/common/tunnel'; + +const globalWithBasePath = GLOBAL_OBJ as { [TUNNEL_BASE_PATH_GLOBAL]?: string }; + +describe('applyTunnelBasePath', () => { + afterEach(() => { + delete globalWithBasePath.__sentry_sveltekit_base_path; + }); + + it('adds the injected base path to the managed tunnel', () => { + globalWithBasePath[TUNNEL_BASE_PATH_GLOBAL] = '/app'; + const options: Parameters[0] = { tunnel: true }; + + applyTunnelBasePath(options); + + expect(options.tunnel).toEqual({ basePath: '/app' }); + }); + + it('leaves the tunnel alone without a base path', () => { + const options: Parameters[0] = { tunnel: true }; + + applyTunnelBasePath(options); + + expect(options.tunnel).toBe(true); + }); +}); diff --git a/packages/sveltekit/test/server-common/handle.test.ts b/packages/sveltekit/test/server-common/handle.test.ts index 8843e63ed1e6..1cfb18963193 100644 --- a/packages/sveltekit/test/server-common/handle.test.ts +++ b/packages/sveltekit/test/server-common/handle.test.ts @@ -2,7 +2,9 @@ import type { EventEnvelopeHeaders, Span } from '@sentry/core'; import { SENTRY_SEGMENT_NAME_SOURCE, HTTP_ROUTE } from '@sentry/conventions/attributes'; import { getCapturedScopesOnSpan, getRootSpan, getSpanDescendants, spanIsSampled, spanToJSON } from '@sentry/core'; import * as SentryCore from '@sentry/core'; -import { NodeClient, setCurrentClient } from '@sentry/node'; +import { NodeClient, serveTunnelIntegration, setCurrentClient } from '@sentry/node'; +import { _INTERNAL_resetManagedTunnelDefaults } from '@sentry/core/server'; +import { TUNNEL_BASE_PATH_GLOBAL } from '../../src/common/tunnel'; import type { Handle } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -141,6 +143,73 @@ describe('sentryHandle with span streaming', () => { }); }); +describe('sentryHandle with the managed tunnel', () => { + const DSN = 'https://public@o1.ingest.sentry.io/1337'; + + function makeTunnelRequest(path: string): Request { + const body = SentryCore.serializeEnvelope(SentryCore.createEnvelope({ dsn: DSN }, [])) as string; + + return new Request(`http://localhost:3000${path}`, { + method: 'POST', + body, + headers: { 'content-length': String(body.length) }, + }); + } + + beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(new Response('forwarded'))), + ); + const client = new NodeClient(getDefaultNodeClientOptions({ dsn: DSN, integrations: [serveTunnelIntegration()] })); + setCurrentClient(client); + client.init(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + _INTERNAL_resetManagedTunnelDefaults(); + }); + + it('answers tunnel requests without resolving the route', async () => { + const mockResolve = vi.fn(); + + const response = await sentryHandle()({ + event: mockEvent({ request: makeTunnelRequest(SentryCore.getTunnelPath(DSN)!), route: { id: null } }), + resolve: mockResolve, + }); + + expect(await response.text()).toBe('forwarded'); + expect(mockResolve).not.toHaveBeenCalled(); + }); + + it('serves the tunnel under the base path `sentrySvelteKit()` set before the handle was created', async () => { + vi.stubGlobal(TUNNEL_BASE_PATH_GLOBAL, '/app'); + const mockResolve = vi.fn(() => Promise.resolve(new Response('app'))); + const handle = sentryHandle(); + + const underBasePath = await handle({ + event: mockEvent({ request: makeTunnelRequest(`/app${SentryCore.getTunnelPath(DSN)!}`), route: { id: null } }), + resolve: mockResolve, + }); + const atRoot = await handle({ + event: mockEvent({ request: makeTunnelRequest(SentryCore.getTunnelPath(DSN)!), route: { id: null } }), + resolve: mockResolve, + }); + + expect(await underBasePath.text()).toBe('forwarded'); + expect(await atRoot.text()).toBe('app'); + }); + + it('resolves every other request', async () => { + const mockResolve = vi.fn(() => Promise.resolve(new Response('app'))); + + const response = await sentryHandle()({ event: mockEvent(), resolve: mockResolve }); + + expect(await response.text()).toBe('app'); + }); +}); + describe('sentryHandle', () => { describe.each([ // isSync, isError, expectedResponse diff --git a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts index c78099729998..7cae5e1097c4 100644 --- a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts +++ b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts @@ -68,10 +68,10 @@ describe('sentrySvelteKit()', () => { expect(plugins).toBeInstanceOf(Array); // 1 kit config resolver + 1 browser-tracing variant resolver + 1 OpenTelemetry API resolver - // + 1 auto instrument plugin + // + 1 tunnel base path plugin + 1 auto instrument plugin // + 1 orchestrion plugin + 1 build flag plugin + 1 global values injection plugin + 1 modified main plugin // + 3 custom plugins - expect(plugins).toHaveLength(11); + expect(plugins).toHaveLength(12); }); it('returns the custom sentry source maps upload plugin, unmodified sourcemaps plugins and the auto-instrument plugin by default', async () => { @@ -84,6 +84,8 @@ describe('sentrySvelteKit()', () => { 'sentry-sveltekit-browser-tracing-variant', // OpenTelemetry API resolver: 'sentry-sveltekit-opentelemetry-api', + // tunnel base path plugin: + 'sentry-sveltekit-tunnel-base-path', // auto instrument plugin: 'sentry-auto-instrumentation', // orchestrion build-time instrumentation plugin: @@ -103,7 +105,7 @@ describe('sentrySvelteKit()', () => { it("doesn't return the sentry source maps plugins if autoUploadSourcemaps is `false`", async () => { const plugins = await getSentrySvelteKitPlugins({ autoUploadSourceMaps: false }); - expect(plugins).toHaveLength(6); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + auto instrument + orchestrion + build flag + expect(plugins).toHaveLength(7); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + tunnel base path + auto instrument + orchestrion + build flag }); it("doesn't return the sentry source maps plugins if `NODE_ENV` is development", async () => { @@ -111,9 +113,9 @@ describe('sentrySvelteKit()', () => { process.env.NODE_ENV = 'development'; const plugins = await getSentrySvelteKitPlugins({ autoUploadSourceMaps: true, autoInstrument: true }); - const instrumentPlugin = plugins[3]; + const instrumentPlugin = plugins[4]; - expect(plugins).toHaveLength(7); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + auto instrument + orchestrion + build flag + global values injection + expect(plugins).toHaveLength(8); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + tunnel base path + auto instrument + orchestrion + build flag + global values injection expect(instrumentPlugin?.name).toEqual('sentry-auto-instrumentation'); process.env.NODE_ENV = previousEnv; @@ -122,7 +124,7 @@ describe('sentrySvelteKit()', () => { it("doesn't return the auto instrument plugin if autoInstrument is `false`", async () => { const plugins = await getSentrySvelteKitPlugins({ autoInstrument: false }); const pluginNames = plugins.map(plugin => plugin.name); - expect(plugins).toHaveLength(10); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + orchestrion + build flag + global values injection + 1 modified main plugin + 3 custom plugins + expect(plugins).toHaveLength(11); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + tunnel base path + orchestrion + build flag + global values injection + 1 modified main plugin + 3 custom plugins expect(pluginNames).not.toContain('sentry-auto-instrumentation'); }); @@ -234,7 +236,7 @@ describe('sentrySvelteKit()', () => { // just to ignore the source maps plugin: autoUploadSourceMaps: false, }); - const plugin = plugins[3]!; + const plugin = plugins[4]!; expect(plugin.name).toEqual('sentry-auto-instrumentation'); expect(makePluginSpy).toHaveBeenCalledWith({ diff --git a/packages/sveltekit/test/vite/tunnelBasePath.test.ts b/packages/sveltekit/test/vite/tunnelBasePath.test.ts new file mode 100644 index 000000000000..a3faa1ba4509 --- /dev/null +++ b/packages/sveltekit/test/vite/tunnelBasePath.test.ts @@ -0,0 +1,60 @@ +import type { Plugin } from 'vite'; +import { describe, expect, it } from 'vitest'; +import type { ResolvedKitConfig } from '../../src/vite/kitConfig'; +import { makeTunnelBasePathPlugin } from '../../src/vite/tunnelBasePath'; + +type Hook = (...args: unknown[]) => unknown; + +async function setup(kitConfig: ResolvedKitConfig): Promise { + const plugin = makeTunnelBasePathPlugin(() => Promise.resolve(kitConfig)); + await (plugin.configResolved as Hook)({}); + + return plugin; +} + +describe('makeTunnelBasePathPlugin', () => { + it('imports the base path first in the hooks and instrumentation files', async () => { + const plugin = await setup({ paths: { base: '/app' } }); + const transform = plugin.transform as Hook; + + for (const id of ['/p/src/hooks.server.ts', '/p/src/hooks.client.js', '/p/src/instrumentation.server.ts']) { + expect(transform('const a = 1;', id)).toEqual({ + code: `import ${JSON.stringify('\0sentry-sveltekit-tunnel-base-path')};\nconst a = 1;`, + map: expect.objectContaining({ mappings: expect.any(String) }), + }); + } + expect(transform('const a = 1;', '/p/src/routes/+page.svelte')).toBeNull(); + // The module only assigns a global, so it must survive tree-shaking. + expect((plugin.resolveId as Hook)('\0sentry-sveltekit-tunnel-base-path')).toEqual({ + id: '\0sentry-sveltekit-tunnel-base-path', + moduleSideEffects: true, + }); + expect((plugin.load as Hook)('\0sentry-sveltekit-tunnel-base-path')).toBe( + 'globalThis["__sentry_sveltekit_base_path"] = "/app";', + ); + }); + + it('follows custom hooks file locations', async () => { + const plugin = await setup({ paths: { base: '/app' }, files: { hooks: { server: 'src/server/hooks' } } }); + + expect((plugin.transform as Hook)('', '/p/src/server/hooks.ts')).not.toBeNull(); + expect((plugin.transform as Hook)('', '/p/src/hooks.server.ts')).toBeNull(); + }); + + it('follows the hooks file forms SvelteKit accepts: an index file, custom module extensions, and query ids', async () => { + const plugin = await setup({ paths: { base: '/app' }, moduleExtensions: ['.js', '.ts', '.coffee'] }); + const transform = plugin.transform as Hook; + + expect(transform('', '/p/src/hooks.server/index.ts')).not.toBeNull(); + expect(transform('', '/p/src/hooks.client.coffee')).not.toBeNull(); + expect(transform('', '/p/src/hooks.server.ts?v=123')).not.toBeNull(); + expect(transform('', '/p/src/hooks.server/utils.ts')).toBeNull(); + expect(transform('', '/p/src/hooks.server.tsx')).toBeNull(); + }); + + it('does nothing without a base path', async () => { + const plugin = await setup({}); + + expect((plugin.transform as Hook)('', '/p/src/hooks.server.ts')).toBeNull(); + }); +}); diff --git a/packages/sveltekit/test/worker/cloudflare.test.ts b/packages/sveltekit/test/worker/cloudflare.test.ts index dea694b292ec..946854d0b102 100644 --- a/packages/sveltekit/test/worker/cloudflare.test.ts +++ b/packages/sveltekit/test/worker/cloudflare.test.ts @@ -63,7 +63,11 @@ describe('initCloudflareSentryHandle', () => { { // SvelteKit emits its own OpenTelemetry spans, so it opts into the tracer provider rather than // inheriting Cloudflare's no-provider default. - options: expect.objectContaining({ dsn: options.dsn, enableOpenTelemetrySetup: true }), + options: expect.objectContaining({ + dsn: options.dsn, + enableOpenTelemetrySetup: true, + _extendsDefaultIntegrations: true, + }), request, context, captureErrors: false, @@ -74,6 +78,25 @@ describe('initCloudflareSentryHandle', () => { expect(resolve).toHaveBeenCalledTimes(1); }); + it('does not mark a defaultIntegrations list the user passed as extending the SDK defaults', async () => { + const { options, event, resolve } = getHandlerInput(); + + // @ts-expect-error - resolving an empty object is enough for this test + vi.mocked(wrapRequestHandler).mockImplementationOnce((_, cb) => cb()); + + const handle = initCloudflareSentryHandle({ ...options, defaultIntegrations: [] }); + + // @ts-expect-error - only passing a partial event object + await handle({ event, resolve }); + + expect(wrapRequestHandler).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ defaultIntegrations: [], _extendsDefaultIntegrations: false }), + }), + expect.any(Function), + ); + }); + it('adds flag to skip request isolation in subsequent sentry handler', async () => { const { options, event, resolve } = getHandlerInput(); const locals = {}; diff --git a/packages/tanstackstart-react/README.md b/packages/tanstackstart-react/README.md index c16a1d85d7fa..e1bf0237a928 100644 --- a/packages/tanstackstart-react/README.md +++ b/packages/tanstackstart-react/README.md @@ -1,6 +1,10 @@

- Sentry + + + + Sentry +

diff --git a/packages/tanstackstart-react/package.json b/packages/tanstackstart-react/package.json index 8bf4cf0ac412..7f8e27ab2ebe 100644 --- a/packages/tanstackstart-react/package.json +++ b/packages/tanstackstart-react/package.json @@ -53,7 +53,7 @@ "dependencies": { "@opentelemetry/api": "^1.9.1", "@sentry/browser-utils": "11.9.0", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/core": "11.9.0", "@sentry/node": "11.9.0", "@sentry/react": "11.9.0", @@ -62,6 +62,8 @@ "@sentry/bundler-plugins": "11.9.0" }, "devDependencies": { + "@sentry-internal/rollup-utils": "11.9.0", + "@types/node": "^20.19.0", "vite": "^8.3.1" }, "scripts": { @@ -84,9 +86,6 @@ "test:watch": "vitest --watch", "yalc:publish": "yalc publish --push --sig" }, - "volta": { - "extends": "../../package.json" - }, "sideEffects": false, "nx": { "targets": { diff --git a/packages/tanstackstart-react/src/client/index.ts b/packages/tanstackstart-react/src/client/index.ts index 73c7e4cf7b24..b157b4f31173 100644 --- a/packages/tanstackstart-react/src/client/index.ts +++ b/packages/tanstackstart-react/src/client/index.ts @@ -62,3 +62,12 @@ export function createSentryTunnelRoute(_options: CreateSentryTunnelRouteOptions * can statically import it (the call is server-guarded and tree-shaken from the client bundle). */ export function registerSentryServerTunnelRoute(_path: string): void {} + +/** + * No-op stub for client-side builds. The server entry the Vite plugin wraps only runs on the server. + * + * @internal + */ +export function _INTERNAL_wrapServerEntryWithSentryTunnel(entry: T, _options?: unknown): T { + return entry; +} diff --git a/packages/tanstackstart-react/src/client/tunnelRoute.ts b/packages/tanstackstart-react/src/client/tunnelRoute.ts index b22612c123d0..f4a676b63d70 100644 --- a/packages/tanstackstart-react/src/client/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/client/tunnelRoute.ts @@ -1,15 +1,25 @@ -import { consoleSandbox } from '@sentry/core'; +import { _INTERNAL_applyTunnelDefaults as applyTunnelDefaults, consoleSandbox } from '@sentry/core'; import type { BrowserOptions as ReactBrowserOptions } from '@sentry/react'; -declare const __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: string | undefined; +declare const __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: string | true | undefined; +declare const __SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__: string | undefined; let hasWarnedAboutManagedTunnelRouteOverride = false; /** * Applies the managed tunnel route from `sentryTanstackStart({ tunnelRoute: ... })` unless the user already - * configured an explicit runtime `tunnel` option in `Sentry.init()`. + * configured an explicit runtime `tunnel` option in `Sentry.init()`, then serves the tunnel under the base path + * Start routes on. A `basePath` the user set wins. */ export function applyTunnelRouteOption(options: ReactBrowserOptions): void { + applyManagedTunnelRoute(options); + + const basePath = + typeof __SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__ !== 'undefined' ? __SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__ : ''; + options.tunnel = applyTunnelDefaults(options.tunnel, { basePath }); +} + +function applyManagedTunnelRoute(options: ReactBrowserOptions): void { const managedTunnelRoute = typeof __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__ !== 'undefined' ? __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__ : undefined; @@ -18,7 +28,7 @@ export function applyTunnelRouteOption(options: ReactBrowserOptions): void { } if (options.tunnel) { - if (!hasWarnedAboutManagedTunnelRouteOverride) { + if (options.tunnel !== managedTunnelRoute && !hasWarnedAboutManagedTunnelRouteOverride) { hasWarnedAboutManagedTunnelRouteOverride = true; consoleSandbox(() => { // eslint-disable-next-line no-console diff --git a/packages/tanstackstart-react/src/index.types.ts b/packages/tanstackstart-react/src/index.types.ts index a5464d731373..9b81217a239a 100644 --- a/packages/tanstackstart-react/src/index.types.ts +++ b/packages/tanstackstart-react/src/index.types.ts @@ -49,3 +49,4 @@ export declare const sentryGlobalRequestMiddleware: typeof serverSdk.sentryGloba export declare const sentryGlobalFunctionMiddleware: typeof serverSdk.sentryGlobalFunctionMiddleware; export declare const createSentryTunnelRoute: typeof serverSdk.createSentryTunnelRoute; export declare const registerSentryServerTunnelRoute: typeof serverSdk.registerSentryServerTunnelRoute; +export declare const _INTERNAL_wrapServerEntryWithSentryTunnel: typeof serverSdk._INTERNAL_wrapServerEntryWithSentryTunnel; diff --git a/packages/tanstackstart-react/src/server/index.ts b/packages/tanstackstart-react/src/server/index.ts index 2d2b1bb85d26..cef85cd391c4 100644 --- a/packages/tanstackstart-react/src/server/index.ts +++ b/packages/tanstackstart-react/src/server/index.ts @@ -9,7 +9,11 @@ export { init } from './sdk'; export { wrapFetchWithSentry } from './wrapFetchWithSentry'; export { wrapMiddlewaresWithSentry } from './middleware'; export { sentryGlobalRequestMiddleware, sentryGlobalFunctionMiddleware } from './globalMiddleware'; -export { createSentryTunnelRoute, registerSentryServerTunnelRoute } from './tunnelRoute'; +export { + createSentryTunnelRoute, + registerSentryServerTunnelRoute, + wrapServerEntryWithSentryTunnel as _INTERNAL_wrapServerEntryWithSentryTunnel, +} from './tunnelRoute'; /** * A no-op stub of the replay integration for the server. Router setup code is shared between client and server, diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts index 89501d8a2da4..1d4d09b80553 100644 --- a/packages/tanstackstart-react/src/server/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -1,5 +1,13 @@ import { URL_PATH } from '@sentry/conventions/attributes'; +import type { ServerTunnelOptions } from '@sentry/core'; import { dsnToString, escapeStringForRegex, getClient, handleTunnelRequest } from '@sentry/core'; +import { + _INTERNAL_forwardTunnelRequest as forwardTunnelRequest, + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, + _INTERNAL_isServedTunnelRequest as isServedTunnelRequest, + _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, +} from '@sentry/core/server'; +import type { ServerEntry } from './wrapFetchWithSentry'; const registeredTunnelRoutePaths = new Set(); @@ -84,11 +92,53 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions) ); } - return handleTunnelRequest({ - request, - allowedDsns, - }); + return handleTunnelRequest(request, { allowedDsns }); }, }, }; } + +/** + * Answers managed tunnel requests before the server entry runs. The `sentryTanstackStart` Vite plugin applies this + * to the app's server entry automatically, with its `tunnelRoute` and the base path Start routes on. + * + * `tunnelRoute` enables the managed tunnel on every server client the same way `serveTunnelIntegration` does, unless + * that integration is set up too, and the tunnel is then served from the client's config like in every other SDK. + * + * @internal Only the code the Vite plugin generates calls this, so its parameters can change at any time. + */ +export function wrapServerEntryWithSentryTunnel( + entry: T, + tunnelRoute?: ServerTunnelOptions, + basePath?: string, +): T { + // Runs when the server entry loads, before any request. Requests still carry Start's base path here, so the tunnel + // is served under it. + setManagedTunnelDefaults({ tunnel: tunnelRoute, forward: forwardTunnelRequest, basePath }); + + // A class entry, like Cloudflare's `WorkerEntrypoint`, can't be spread into an object. The tunnel is then served + // by the wrapped default server entry it calls. + if (typeof entry === 'function') { + return entry; + } + + // Keeps the prototype, so a class instance entry keeps its other handlers, like `scheduled` or `queue`. `fetch` is + // defined rather than assigned, which a read-only `fetch` on a frozen prototype would reject. + return Object.create(Object.getPrototypeOf(entry), { + ...Object.getOwnPropertyDescriptors(entry), + fetch: { + // Cloudflare Workers pass `env` and `ctx` after the request, and `withSentry` needs `ctx` to flush. + value: (request: Request, ...rest: unknown[]) => { + // Start would render the app for any other method on a tunnel path, running its root loaders. + if (request.method !== 'POST' && isServedTunnelRequest({ method: 'POST', url: request.url })) { + return new Response(null, { status: 405, headers: { Allow: 'POST' } }); + } + + return handleServedTunnelRequest(request) ?? entry.fetch(request, ...rest); + }, + writable: true, + configurable: true, + enumerable: true, + }, + }) as T; +} diff --git a/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts b/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts new file mode 100644 index 000000000000..f759cc125fc2 --- /dev/null +++ b/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts @@ -0,0 +1,160 @@ +import { readFile } from 'node:fs/promises'; +import * as path from 'node:path'; +import type { BuildEnvironmentOptions, Plugin, ResolvedConfig } from 'vite'; + +/** `nitro/vite` builds the final server in an environment with this name. */ +const NITRO_ENVIRONMENT = 'nitro'; + +/** The environment Vite passes to `applyToEnvironment`. */ +export type PluginEnvironment = Parameters>[0]; + +type BundlerSourceMapConfig = { output?: { sourcemap?: unknown } }; + +type NitroSourceMapConfig = { + sourcemap?: unknown; + rollupConfig?: BundlerSourceMapConfig; + rolldownConfig?: BundlerSourceMapConfig; +}; + +/** The parts of the Nitro instance that this plugin uses. */ +type Nitro = { + options: { + sourcemap: unknown; + experimental: { sourcemapMinify?: false }; + // c12 keeps each config source that Nitro merged: the `nitro()` options, `nitro.config.ts`, presets and more. + // It is internal until Nitro exposes the layers: https://github.com/nitrojs/nitro/issues/4771 + _c12?: { layers?: { config?: NitroSourceMapConfig | null }[] }; + }; +}; + +/** + * Tells the plugin that deletes source maps after the upload whether the source maps of an environment are only + * there because Sentry enabled them. + */ +export type SourceMapsEnablerApi = { hasSentryEnabledSourceMaps(environmentName: string): boolean }; + +/** + * Whether Nitro bundles the output of this environment again. With `nitro/vite`, every server environment besides + * `nitro` is a Nitro service, such as TanStack Start's `ssr` environment. + */ +export function isNitroService(environment: PluginEnvironment): boolean { + return ( + environment.name !== NITRO_ENVIRONMENT && + environment.config.consumer === 'server' && + NITRO_ENVIRONMENT in environment.config.environments + ); +} + +/** + * Keeps the sources in the source maps of Nitro's server build. Enables these source maps, unless a Nitro config + * source or `build.rolldownOptions.output.sourcemap` of the Vite config sets them, or the Vite config disables them. + */ +export function makeEnableNitroSourceMapsPlugin({ debug }: { debug?: boolean }): Plugin & { + api: SourceMapsEnablerApi; + nitro: { name: string; setup: (nitro: Nitro) => void }; +} { + let enabledSourceMaps = false; + let viteConfigPreventsSourceMaps = false; + + return { + name: 'sentry-tanstackstart-nitro-source-maps', + apply: 'build', + // The `config` hook must run before the one of `nitro/vite`, which runs the Nitro modules. + enforce: 'pre', + api: { + hasSentryEnabledSourceMaps: environmentName => enabledSourceMaps && environmentName === NITRO_ENVIRONMENT, + }, + config(viteConfig) { + // Nitro overrides `build.sourcemap` for its environments, but `false` shows that the user wants no source maps. + viteConfigPreventsSourceMaps = + viteConfig.build?.sourcemap === false || hasOutputSourceMapSetting(viteConfig.build); + }, + // `nitro/vite` installs the Nitro module of each Vite plugin after it merged all config sources. + nitro: { + name: 'sentry-tanstackstart-nitro-source-maps', + setup(nitro) { + // Otherwise Nitro removes `sourcesContent` from its source maps, and `mappings` from maps with only + // `node_modules` sources. Nitro only allows `false`. + nitro.options.experimental.sourcemapMinify = false; + + const layers = nitro.options._c12?.layers; + // Without the config sources, Sentry cannot tell if the user disabled source maps. + if (!layers || viteConfigPreventsSourceMaps || layers.some(layer => hasNitroSourceMapSetting(layer.config))) { + return; + } + + nitro.options.sourcemap = 'hidden'; + enabledSourceMaps = true; + logDebug(debug, "Enabled source map generation for the Nitro server build with `nitro.sourcemap: 'hidden'`."); + }, + }, + }; +} + +/** + * Nitro reads the output of its services as plain code, so its source maps would end at that output. Loading the + * service's source map with the code lets the bundler compose both maps to the original source files. + */ +export function makeComposeNitroServiceSourceMapsPlugin(): Plugin { + return { + name: 'sentry-tanstackstart-nitro-service-source-maps', + apply: 'build', + applyToEnvironment: environment => environment.name === NITRO_ENVIRONMENT, + async load(id) { + const serviceOutDirs = getNitroServiceOutDirs(this.environment.config); + if (!serviceOutDirs.some(outDir => isInDirectory(id, outDir))) { + return null; + } + + try { + const [code, map] = await Promise.all([readFile(id, 'utf8'), readFile(`${id}.map`, 'utf8')]); + return { code, map }; + } catch { + // A chunk without a source map: let Nitro load the code as usual. + return null; + } + }, + }; +} + +/** The output directories of the environments that Nitro bundles again. Empty in a build without Nitro. */ +export function getNitroServiceOutDirs(config: ResolvedConfig): string[] { + if (!(NITRO_ENVIRONMENT in config.environments)) { + return []; + } + + return Object.entries(config.environments) + .filter(([name, environment]) => name !== NITRO_ENVIRONMENT && environment.consumer === 'server') + .map(([, environment]) => path.resolve(config.root, environment.build.outDir)); +} + +function isInDirectory(file: string, directory: string): boolean { + const relativePath = path.relative(directory, file); + return ( + relativePath !== '' && + relativePath !== '..' && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath) + ); +} + +/** `output.sourcemap` of the bundler config overrides `build.sourcemap` in the build output. */ +export function hasOutputSourceMapSetting( + build: Pick | undefined, +): boolean { + return [build?.rolldownOptions?.output ?? []].flat().some(output => output.sourcemap !== undefined); +} + +/** In a Nitro config, `output.sourcemap` of the bundler config overrides `sourcemap` in the build output. */ +function hasNitroSourceMapSetting(config: NitroSourceMapConfig | null | undefined): boolean { + return [config?.sourcemap, config?.rollupConfig?.output?.sourcemap, config?.rolldownConfig?.output?.sourcemap].some( + setting => setting !== undefined, + ); +} + +function logDebug(debug: boolean | undefined, message: string): void { + if (debug) { + // eslint-disable-next-line no-console + console.log(`[Sentry] ${message}`); + } +} diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index c3359c077d1b..6d56f81a63e7 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -2,10 +2,11 @@ import type { BuildTimeOptionsBase, ReactComponentAnnotationOptions } from '@sen import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; import type { Plugin } from 'vite'; import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware'; +import { makeComposeNitroServiceSourceMapsPlugin, makeEnableNitroSourceMapsPlugin } from './nitroSourceMaps'; import { makeRoutePatternPlugin } from './routePatterns'; import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; import type { TunnelRouteOptions } from './tunnelRoute'; -import { makeTunnelRoutePlugin } from './tunnelRoute'; +import { makeServerEntryTunnelPlugin, makeTunnelBasePathPlugin, makeTunnelRoutePlugin } from './tunnelRoute'; /** * Build-time options for the Sentry TanStack Start SDK. @@ -36,14 +37,13 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { /** * Configures a framework-managed same-origin tunnel route for Sentry envelopes. * - * This creates a TanStack Start server route backed by `createSentryTunnelRoute()` and applies the resulting path - * as the default `tunnel` option on the client. + * The server entry answers the tunnel before Start's request middleware and routing, and the resulting path is + * applied as the default `tunnel` option on the client. * * You can pass: - * - `true` to generate an opaque route path per dev session or production build. + * - `true` to serve the path derived from the DSN, the same one `Sentry.init({ tunnel: true })` uses. * - `'/custom-path'` to use a fixed static route path. - * - `{ allowedDsns, path }` for full control. If `allowedDsns` is omitted or empty, the tunnel route derives the DSN - * from the active server Sentry client at runtime. + * - `{ allowedDsns, path }` for full control. The DSN of the active server Sentry client is always allowed too. * * If you also pass `tunnel` to `Sentry.init()`, that explicit runtime option wins and a warning is emitted because * the managed tunnel route is being bypassed. @@ -76,7 +76,12 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * @returns An array of Vite plugins */ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): Plugin[] { - const plugins: Plugin[] = [makeRoutePatternPlugin()]; + const tunnelBasePath = makeTunnelBasePathPlugin(); + const plugins: Plugin[] = [ + makeRoutePatternPlugin(), + tunnelBasePath.plugin, + makeServerEntryTunnelPlugin(options.tunnelRoute, tunnelBasePath.getBasePath), + ]; if (options.tunnelRoute) { plugins.push(makeTunnelRoutePlugin(options.tunnelRoute, options.debug)); @@ -92,7 +97,7 @@ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): P // (CommonJS) deps via `ssr.noExternal`, which the `vite dev` SSR module runner can't evaluate. plugins.push(sentryOrchestrionPlugin({ buildTimeInstrumentation: options.buildTimeInstrumentation })); - plugins.push(...makeAddSentryVitePlugin(options)); + plugins.push(...makeAddSentryVitePlugin(options), makeComposeNitroServiceSourceMapsPlugin()); // middleware auto-instrumentation if (options.autoInstrumentMiddleware !== false) { @@ -102,7 +107,7 @@ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): P // source maps const sourceMapsDisabled = options.sourcemaps?.disable === true || options.sourcemaps?.disable === 'disable-upload'; if (!sourceMapsDisabled) { - plugins.push(...makeEnableSourceMapsVitePlugin(options)); + plugins.push(...makeEnableSourceMapsVitePlugin(options), makeEnableNitroSourceMapsPlugin(options)); } return plugins; diff --git a/packages/tanstackstart-react/src/vite/sourceMaps.ts b/packages/tanstackstart-react/src/vite/sourceMaps.ts index f84c52692899..c263011142ae 100644 --- a/packages/tanstackstart-react/src/vite/sourceMaps.ts +++ b/packages/tanstackstart-react/src/vite/sourceMaps.ts @@ -1,6 +1,13 @@ import { sentryVitePlugin } from '@sentry/bundler-plugins/vite'; import * as path from 'node:path'; -import type { BuildEnvironmentOptions, Plugin, ResolvedConfig, UserConfig } from 'vite'; +import type { Plugin, ResolvedConfig, UserConfig } from 'vite'; +import { + getNitroServiceOutDirs, + hasOutputSourceMapSetting, + isNitroService, + type PluginEnvironment, + type SourceMapsEnablerApi, +} from './nitroSourceMaps'; import type { SentryTanstackStartOptions } from './sentryTanstackStart'; type FilesToDeleteAfterUpload = string | string[] | undefined; @@ -30,12 +37,6 @@ export function rewriteTanstackStartSource(source: string, _map: unknown, contex return path.relative(process.cwd(), absolutePath).split(path.sep).join('/'); } -/** - * Tells the plugin that deletes source maps after the upload whether the source maps of an environment are only - * there because Sentry enabled them. - */ -type SourceMapsEnablerApi = { hasSentryEnabledSourceMaps(environmentName: string): boolean }; - /** * A Sentry plugin for adding the @sentry/bundler-plugins/vite to automatically upload source maps to Sentry. */ @@ -127,7 +128,14 @@ export function makeAddSentryVitePlugin(options: SentryTanstackStartOptions): Pl }, }); - return [configPlugin, ...sentryPlugins]; + // Nitro bundles the output of its services again. Debug IDs from a service would end up in Nitro's output and point + // to the service's source maps instead of Nitro's. + const scopedSentryPlugins = sentryPlugins.map(plugin => ({ + ...plugin, + applyToEnvironment: (environment: PluginEnvironment) => !isNitroService(environment), + })); + + return [configPlugin, ...scopedSentryPlugins]; } /** @@ -135,10 +143,12 @@ export function makeAddSentryVitePlugin(options: SentryTanstackStartOptions): Pl * such as `./**\/*.map` does not enter dot directories like `.output`, where Nitro puts the client build. */ function getSentryEnabledSourceMapGlobs(config: ResolvedConfig): string[] { + const serviceOutDirs = getNitroServiceOutDirs(config); const outDirs = Object.entries(config.environments) .filter(([name]) => hasSentryEnabledSourceMaps(config, name)) .filter(([, environment]) => !hasOutputSourceMapSetting(environment.build)) - .flatMap(([, environment]) => getOutputDirs(config.root, environment.build)); + .flatMap(([, environment]) => getOutputDirs(config.root, environment.build)) + .filter(outDir => !serviceOutDirs.includes(outDir)); return [...new Set(outDirs)].map(outDir => { const relativeOutDir = path.relative(process.cwd(), outDir).split(path.sep).join('/'); @@ -153,11 +163,6 @@ function getOutputDirs(root: string, build: ResolvedConfig['environments'][strin return outputs.map(output => path.resolve(root, output.dir ?? build.outDir)); } -/** `output.sourcemap` of the bundler config overrides `build.sourcemap` in the build output. */ -function hasOutputSourceMapSetting(build: Pick | undefined): boolean { - return [build?.rolldownOptions?.output ?? []].flat().some(output => output.sourcemap !== undefined); -} - /** Vite tells plugins its version since Vite 7. */ function isVite8OrLater(viteVersion: string | undefined): boolean { return Number(viteVersion?.split('.')[0]) >= 8; diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index 06f3337b4e5e..cb69c605b918 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -1,3 +1,9 @@ +import type { ServerTunnelOptions } from '@sentry/core'; +import { _INTERNAL_normalizeTunnelBasePath as normalizeTunnelBasePath } from '@sentry/core'; +import { + _INTERNAL_forwardTunnelRequest as forwardTunnelRequest, + _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, +} from '@sentry/core/server'; import type { Plugin } from 'vite'; export type TunnelRouteOptions = @@ -14,43 +20,11 @@ export type TunnelRouteOptions = /** * Controls the public route path used by the managed tunnel route. * - * If omitted, an opaque path is generated once per dev session or production build. + * If omitted, the path is derived from the DSN at runtime, the same way `Sentry.init({ tunnel: true })` does. */ path?: string; }; -const MANAGED_TUNNEL_ROUTE_IMPORT = 'SentryManagedTunnelRouteImport'; -const MANAGED_TUNNEL_ROUTE_NAME = 'SentryManagedTunnelRoute'; -const MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY = '__SENTRY_INTERNAL_TANSTACKSTART_TUNNEL_ROUTE__'; - -const VIRTUAL_TUNNEL_ROUTE_ID = 'virtual:sentry-tanstackstart-react/tunnel-route'; -const RESOLVED_VIRTUAL_TUNNEL_ROUTE_ID = `\0${VIRTUAL_TUNNEL_ROUTE_ID}`; - -function generateRandomTunnelRoute(): string { - const randomPath = Array.from({ length: 8 }, () => Math.floor(Math.random() * 36).toString(36)).join(''); - - return `/${randomPath}`; -} - -export function resolveTunnelRoute(tunnel: true | string): string { - if (typeof tunnel === 'string') { - return tunnel; - } - - if (process.env[MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY]) { - return process.env[MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY]; - } - - const resolvedTunnelRoute = generateRandomTunnelRoute(); - process.env[MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY] = resolvedTunnelRoute; - return resolvedTunnelRoute; -} - -type NormalizedTunnelRouteOptions = { - resolvedPath: string; - allowedDsns: string[] | undefined; -}; - function validateStaticPath(path: string): void { if (!path.startsWith('/') || path.includes('?') || path.includes('#')) { throw new Error( @@ -59,156 +33,172 @@ function validateStaticPath(path: string): void { } } -function normalizeTunnelRouteOptions(options: TunnelRouteOptions): NormalizedTunnelRouteOptions { +/** + * Maps `tunnelRoute` onto the options `serveTunnelIntegration` takes, so both are served the same way. + */ +export function normalizeTunnelRouteOptions(options: TunnelRouteOptions): ServerTunnelOptions { if (options === true) { - return { resolvedPath: resolveTunnelRoute(true), allowedDsns: undefined }; + return {}; } if (typeof options === 'string') { validateStaticPath(options); - return { - resolvedPath: resolveTunnelRoute(options), - allowedDsns: undefined, - }; - } - const allowedDsns = options.allowedDsns && options.allowedDsns.length > 0 ? options.allowedDsns : undefined; - const path = options.path; + return { path: options }; + } - if (path) { - validateStaticPath(path); + if (options.path) { + validateStaticPath(options.path); } - return { resolvedPath: resolveTunnelRoute(path || true), allowedDsns }; + return { + path: options.path || undefined, + allowedDsns: options.allowedDsns?.length ? options.allowedDsns : undefined, + }; } // `routeTree.gen.ts` quote style follows `tsr.config.json#quoteStyle` (`single` | `double`), // so we check both forms for each route-identifying key. const ROUTE_CONFLICT_KEYS = ['fullPath', 'path', 'id'] as const; -function hasRouteConflict(source: string, resolvedTunnelRoute: string): boolean { - const literals = [`'${resolvedTunnelRoute}'`, `"${resolvedTunnelRoute}"`]; +function hasRouteConflict(source: string, tunnelPath: string): boolean { + const literals = [`'${tunnelPath}'`, `"${tunnelPath}"`]; return ROUTE_CONFLICT_KEYS.some(key => literals.some(literal => source.includes(`${key}: ${literal}`))); } -function injectAfterLastImport(source: string, statement: string): string { - const importMatches = [...source.matchAll(/^import .+$/gm)]; - const lastImport = importMatches.at(-1); +/** + * Tells the client SDK which tunnel to use. The server entry wrapper serves it. + */ +export function makeTunnelRoutePlugin(options: TunnelRouteOptions, debug?: boolean): Plugin { + const { path } = normalizeTunnelRouteOptions(options); - if (lastImport?.index === undefined) { - throw new Error( - '[@sentry/tanstackstart-react] Failed to inject the managed tunnel route because `routeTree.gen.ts` imports could not be located.', - ); + if (debug) { + // eslint-disable-next-line no-console + console.log(`[@sentry/tanstackstart-react] Tunnel route: ${path ?? 'derived from the DSN'}`); } - const insertIndex = lastImport.index + lastImport[0].length; - return `${source.slice(0, insertIndex)}\n${statement}${source.slice(insertIndex)}`; -} - -export function injectManagedTunnelRoute(source: string, resolvedTunnelRoute: string): string { - if (source.includes(VIRTUAL_TUNNEL_ROUTE_ID)) { - return source; - } + return { + name: 'sentry-tanstackstart-tunnel-route', + config() { + return { define: { __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: path ? JSON.stringify(path) : 'true' } }; + }, + // The server entry wrapper answers POSTs to a fixed path before Start's routing, so an app route on that path + // would silently lose them. + transform(source, id) { + if (path && /[\\/]routeTree\.gen\.ts$/.test(id) && hasRouteConflict(source, path)) { + throw new Error( + `[@sentry/tanstackstart-react] Cannot register managed tunnel route "${path}" because an existing TanStack Start route already uses that path.`, + ); + } - if (hasRouteConflict(source, resolvedTunnelRoute)) { - throw new Error( - `[@sentry/tanstackstart-react] Cannot register managed tunnel route "${resolvedTunnelRoute}" because an existing TanStack Start route already uses that path.`, - ); - } + return null; + }, + }; +} - const serializedTunnelRoute = JSON.stringify(resolvedTunnelRoute); +const START_BASE_PATH_DEFINE = 'process.env.TSS_ROUTER_BASEPATH'; + +/** + * Reads the base path TanStack Start routes on and hands it to the client SDK and the server entry wrapper, so the + * managed tunnel is served under it without the user repeating it. Start resolves it from `router.basepath`, or from + * Vite's `base`, and defines it for every environment. This runs after Start's own `config` hook to see that define. + */ +export function makeTunnelBasePathPlugin(): { plugin: Plugin; getBasePath: () => string } { + let basePath = ''; + + const plugin: Plugin = { + name: 'sentry-tanstackstart-tunnel-base-path', + enforce: 'post', + config(config) { + const startBasePath = config.define?.[START_BASE_PATH_DEFINE]; + basePath = normalizeTunnelBasePath( + typeof startBasePath === 'string' ? parseDefine(startBasePath) : getRouterBasePathFromViteBase(config.base), + ); + + return { define: { __SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__: JSON.stringify(basePath) } }; + }, + }; - let transformedSource = injectAfterLastImport( - source, - `import { Route as ${MANAGED_TUNNEL_ROUTE_IMPORT} } from '${VIRTUAL_TUNNEL_ROUTE_ID}'`, - ); + return { plugin, getBasePath: () => basePath }; +} - const rootRouteChildrenMatch = transformedSource.match( - /const rootRouteChildren(?:\s*:\s*RootRouteChildren)?\s*=\s*\{/, - ); +function parseDefine(value: string): string | undefined { + try { + const parsed: unknown = JSON.parse(value); - if (rootRouteChildrenMatch?.index === undefined) { - throw new Error( - '[@sentry/tanstackstart-react] Failed to inject the managed tunnel route because the generated TanStack route tree did not contain `rootRouteChildren`.', - ); + return typeof parsed === 'string' ? parsed : undefined; + } catch { + return undefined; } +} - const injectedRootRouteChildrenDeclaration = `const ${MANAGED_TUNNEL_ROUTE_NAME} = ${MANAGED_TUNNEL_ROUTE_IMPORT}.update({ - id: ${serializedTunnelRoute}, - path: ${serializedTunnelRoute}, - getParentRoute: () => rootRouteImport, -} as any) - -${rootRouteChildrenMatch[0]} - ${MANAGED_TUNNEL_ROUTE_NAME}: ${MANAGED_TUNNEL_ROUTE_NAME}, -`; - - transformedSource = `${transformedSource.slice(0, rootRouteChildrenMatch.index)}${injectedRootRouteChildrenDeclaration}${transformedSource.slice(rootRouteChildrenMatch.index + rootRouteChildrenMatch[0].length)}`; - - return transformedSource; +// Start's own fallback when `router.basepath` isn't set: an absolute (CDN) `base` doesn't move the app's routes. +function getRouterBasePathFromViteBase(base: string | undefined): string | undefined { + return base && !/^[a-z][a-z\d+.-]*:/i.test(base) && !base.startsWith('//') ? base : undefined; } -export function makeTunnelRoutePlugin(options: TunnelRouteOptions, debug?: boolean): Plugin { - const normalized = normalizeTunnelRouteOptions(options); - const resolvedTunnelRoute = normalized.resolvedPath; - const serializedTunnelRoute = JSON.stringify(resolvedTunnelRoute); - const serializedAllowedDsns = normalized.allowedDsns ? JSON.stringify(normalized.allowedDsns) : undefined; - - if (debug) { - // eslint-disable-next-line no-console - console.log(`[@sentry/tanstackstart-react] Registered tunnel route: ${resolvedTunnelRoute}`); - } +const SDK_PACKAGE = '@sentry/tanstackstart-react'; +const SERVER_ENTRY_ALIAS = 'virtual:tanstack-start-server-entry'; +const ORIGINAL_SERVER_ENTRY_QUERY = '?sentry-tunnel-original'; +// The default entry is used directly when the app has no `src/server.ts`, and by Cloudflare's `main`. +const DEFAULT_SERVER_ENTRY = /\/react-start\/dist\/(?:plugin\/)?default-entry\/(?:esm\/)?server\.[jt]s$/; + +/** + * Wraps the TanStack Start server entry so tunnel requests are answered before Start's own request middleware and + * routing. It does nothing unless the managed tunnel is enabled, either here via `tunnelRoute` or at runtime via + * `Sentry.init({ tunnel: true })`. + */ +export function makeServerEntryTunnelPlugin( + tunnelRoute: TunnelRouteOptions | undefined, + getBasePath: () => string, +): Plugin { + const normalizedTunnelOptions = tunnelRoute ? normalizeTunnelRouteOptions(tunnelRoute) : undefined; + const tunnelOptions = normalizedTunnelOptions ? JSON.stringify(normalizedTunnelOptions) : 'undefined'; + const serverEntryIds = new Set(); + let root = ''; return { - name: 'sentry-tanstackstart-tunnel-route', + name: 'sentry-tanstackstart-server-entry-tunnel', enforce: 'pre', - config() { - return { - define: { - __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: serializedTunnelRoute, - }, - }; - }, - resolveId(source) { - return source === VIRTUAL_TUNNEL_ROUTE_ID ? RESOLVED_VIRTUAL_TUNNEL_ROUTE_ID : null; - }, - load(id) { - if (id !== RESOLVED_VIRTUAL_TUNNEL_ROUTE_ID) { - return null; + configResolved(config) { + root = normalizePath(config.root ?? ''); + const aliases = Array.isArray(config.resolve.alias) ? config.resolve.alias : []; + const serverEntry = aliases.find(alias => alias.find === SERVER_ENTRY_ALIAS); + if (serverEntry) { + serverEntryIds.add(normalizePath(serverEntry.replacement)); } - - return `import { createFileRoute } from '@tanstack/react-router'; -import { registerSentryServerTunnelRoute } from '@sentry/tanstackstart-react'; - -// Server-only: drop the tunnel route's own transaction. Registered synchronously at module evaluation -// (before any request) so streamed spans are filtered from the first request. The SSR guard tree-shakes -// it (and the import) out of the client bundle. -if (import.meta.env.SSR) { - registerSentryServerTunnelRoute(${serializedTunnelRoute}); -} - -export const Route = createFileRoute(${serializedTunnelRoute})({ - server: { - handlers: { - async POST({ request }) { - const Sentry = await import('@sentry/tanstackstart-react'); - return Sentry.createSentryTunnelRoute(${serializedAllowedDsns ? `{ allowedDsns: ${serializedAllowedDsns} }` : `{}`}).handlers.POST({ request }); - }, - // Without a handler for a method, TanStack Start falls back to SSR-rendering the app (running root loaders). - ANY() { - return new Response(null, { status: 405, headers: { Allow: 'POST' } }); - }, }, - }, -}); -`; + // `vite dev` takes each request in this process and passes it on to where the server entry runs, so its + // `http.server` span comes from the SDK `--import` set up here, which the server entry never reaches. + configureServer() { + setManagedTunnelDefaults({ + tunnel: normalizedTunnelOptions, + forward: forwardTunnelRequest, + basePath: getBasePath(), + }); }, - transform(source, id) { - if (!id.endsWith('/routeTree.gen.ts') && !id.endsWith('\\routeTree.gen.ts')) { + async load(id) { + const normalizedId = normalizePath(id); + if (!serverEntryIds.has(normalizedId) && !DEFAULT_SERVER_ENTRY.test(normalizedId)) { return null; } - return injectManagedTunnelRoute(source, resolvedTunnelRoute); + const original = JSON.stringify(`${id}${ORIGINAL_SERVER_ENTRY_QUERY}`); + // The default entry lives in `@tanstack/react-start`, which doesn't depend on this SDK, so resolving the import + // from there only works when the SDK is hoisted (not under Yarn PnP or pnpm without hoisting). The app depends + // on it, so a bundled SDK is resolved from the app. An external one keeps its name, which the output resolves. + const resolved = await this.resolve(SDK_PACKAGE, `${root}/package.json`, { skipSelf: true }); + const sdk = resolved && !resolved.external ? resolved.id : SDK_PACKAGE; + + return `import entry from ${original}; +import { _INTERNAL_wrapServerEntryWithSentryTunnel as wrapServerEntryWithSentryTunnel } from ${JSON.stringify(sdk)}; +export * from ${original}; +export default wrapServerEntryWithSentryTunnel(entry, ${tunnelOptions}, ${JSON.stringify(getBasePath())}); +`; }, }; } + +function normalizePath(id: string): string { + return id.replace(/\\/g, '/'); +} diff --git a/packages/tanstackstart-react/test/client/tunnelRoute.test.ts b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts index 9dca196f072d..924c8a388d61 100644 --- a/packages/tanstackstart-react/test/client/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts @@ -50,6 +50,30 @@ describe('applyTunnelRouteOption()', () => { ); }); + it('turns on the DSN-derived tunnel when the plugin derives the path from the DSN', async () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__', true); + + const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); + + const options: BrowserOptions = { dsn: 'https://public@dsn.ingest.sentry.io/1337' }; + applyTunnelRouteOption(options); + + expect(options.tunnel).toBe(true); + }); + + it('does not warn when the runtime option already matches the managed tunnel', async () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__', true); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); + + const options: BrowserOptions = { dsn: 'https://public@dsn.ingest.sentry.io/1337', tunnel: true }; + applyTunnelRouteOption(options); + + expect(options.tunnel).toBe(true); + expect(warnSpy).not.toHaveBeenCalled(); + }); + it('does nothing when no managed tunnel route was injected', async () => { const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); @@ -61,4 +85,27 @@ describe('applyTunnelRouteOption()', () => { expect(options.tunnel).toBeUndefined(); }); + + it('serves the tunnel under the base path Start routes on', async () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__', '/app'); + + const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); + + const options: BrowserOptions = { tunnel: true }; + applyTunnelRouteOption(options); + + expect(options.tunnel).toEqual({ basePath: '/app' }); + }); + + it('uses a static `tunnelRoute` as written, without the base path', async () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__', '/managed-tunnel'); + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__', '/app'); + + const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); + + const options: BrowserOptions = {}; + applyTunnelRouteOption(options); + + expect(options.tunnel).toBe('/managed-tunnel'); + }); }); diff --git a/packages/tanstackstart-react/test/server/tunnelRoute.test.ts b/packages/tanstackstart-react/test/server/tunnelRoute.test.ts index 0016be5c0785..c4ef0481b765 100644 --- a/packages/tanstackstart-react/test/server/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/server/tunnelRoute.test.ts @@ -39,12 +39,7 @@ describe('createSentryTunnelRoute', () => { const result = await route.handlers.POST({ request }); expect(handleTunnelRequestSpy).toHaveBeenCalledTimes(1); - const [options] = handleTunnelRequestSpy.mock.calls[0]!; - expect(options).toEqual({ - request, - allowedDsns, - }); - expect(options.allowedDsns).toBe(allowedDsns); + expect(handleTunnelRequestSpy).toHaveBeenCalledWith(request, { allowedDsns }); expect(result).toBe(response); }); @@ -71,9 +66,7 @@ describe('createSentryTunnelRoute', () => { const result = await route.handlers.POST({ request }); expect(handleTunnelRequestSpy).toHaveBeenCalledTimes(1); - const [options] = handleTunnelRequestSpy.mock.calls[0]!; - expect(options).toEqual({ - request, + expect(handleTunnelRequestSpy).toHaveBeenCalledWith(request, { allowedDsns: ['http://public@localhost:3031/1337'], }); expect(result).toBe(response); diff --git a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts new file mode 100644 index 000000000000..b35224c6e277 --- /dev/null +++ b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts @@ -0,0 +1,200 @@ +import type { Client } from '@sentry/core'; +import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; +import { _INTERNAL_resetManagedTunnelDefaults, serveTunnelIntegration } from '@sentry/core/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { wrapServerEntryWithSentryTunnel } from '../../src/server/tunnelRoute'; + +const DSN = 'https://public@o1.ingest.sentry.io/1337'; +const TUNNEL_URL = `http://localhost:3000${getTunnelPath(DSN)}`; + +function makeEnvelopeRequest(url: string): Request { + const body = serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as string; + + return new Request(url, { method: 'POST', body, headers: { 'content-length': String(body.length) } }); +} + +/** Sets a client, with `serveTunnelIntegration` set up when `managedTunnel` is given. */ +function setClient(managedTunnel: object | undefined): void { + const options = { dsn: DSN, ignoreSpans: [] }; + const client = { getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client; + getCurrentScope().setClient(client); + if (managedTunnel) { + serveTunnelIntegration(managedTunnel).setup!(client); + } +} + +describe('wrapServerEntryWithSentryTunnel', () => { + const appFetch = vi.fn(() => new Response('app')); + const sentryFetch = vi.fn(() => Promise.resolve(new Response('forwarded'))); + + beforeEach(() => { + vi.stubGlobal('fetch', sentryFetch); + }); + + afterEach(() => { + getCurrentScope().setClient(undefined); + _INTERNAL_resetManagedTunnelDefaults(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('answers the tunnel path when `serveTunnelIntegration` is set up, without calling the app', async () => { + setClient({}); + + const response = await wrapServerEntryWithSentryTunnel({ fetch: appFetch }).fetch(makeEnvelopeRequest(TUNNEL_URL)); + + expect(await response.text()).toBe('forwarded'); + expect(appFetch).not.toHaveBeenCalled(); + }); + + it('hands every request to the app when the managed tunnel is off', async () => { + setClient(undefined); + + const response = await wrapServerEntryWithSentryTunnel({ fetch: appFetch }).fetch(makeEnvelopeRequest(TUNNEL_URL)); + + expect(await response.text()).toBe('app'); + expect(sentryFetch).not.toHaveBeenCalled(); + }); + + it('serves the tunnel with build-time options even without `serveTunnelIntegration`', async () => { + setClient(undefined); + + const response = await wrapServerEntryWithSentryTunnel({ fetch: appFetch }, {}).fetch( + makeEnvelopeRequest(TUNNEL_URL), + ); + + expect(await response.text()).toBe('forwarded'); + }); + + it('rejects other methods on a fixed `tunnelRoute` path instead of rendering the app', async () => { + setClient(undefined); + const entry: { fetch: (request: Request) => Response } = { fetch: appFetch }; + const wrapped = wrapServerEntryWithSentryTunnel(entry, { path: '/monitor' }); + + const response = await wrapped.fetch(new Request('http://localhost:3000/monitor')); + + expect(response.status).toBe(405); + expect(response.headers.get('allow')).toBe('POST'); + expect(appFetch).not.toHaveBeenCalled(); + expect(await (await wrapped.fetch(new Request('http://localhost:3000/other'))).text()).toBe('app'); + }); + + it('rejects other methods on the DSN-derived tunnel path too', async () => { + setClient({}); + const entry: { fetch: (request: Request) => Response } = { fetch: appFetch }; + + const response = await wrapServerEntryWithSentryTunnel(entry).fetch(new Request(TUNNEL_URL)); + + expect(response.status).toBe(405); + expect(response.headers.get('allow')).toBe('POST'); + expect(appFetch).not.toHaveBeenCalled(); + }); + + it('passes other requests and their options to the app', async () => { + setClient({}); + const request = makeEnvelopeRequest('http://localhost:3000/api/users'); + + await wrapServerEntryWithSentryTunnel({ fetch: appFetch }).fetch(request, { context: 1 }); + + expect(appFetch).toHaveBeenCalledWith(request, { context: 1 }); + }); + + it("passes every argument to the app, like a Cloudflare Worker's `env` and `ctx`", async () => { + setClient({}); + const request = makeEnvelopeRequest('http://localhost:3000/api/users'); + const env = { E2E_TEST_DSN: DSN }; + const ctx = { waitUntil: vi.fn(), passThroughOnException: vi.fn() }; + + await wrapServerEntryWithSentryTunnel({ fetch: appFetch }).fetch(request, env, ctx); + + expect(appFetch).toHaveBeenCalledWith(request, env, ctx); + }); + + it("keeps a class instance entry's prototype handlers, like a Worker's `scheduled`", async () => { + setClient({}); + class Handler { + public fetch(_request: Request): Response { + return new Response('app'); + } + + public scheduled(): string { + return 'scheduled'; + } + } + + const wrapped = wrapServerEntryWithSentryTunnel(new Handler()); + + expect(wrapped.scheduled()).toBe('scheduled'); + expect(wrapped).toBeInstanceOf(Handler); + expect(await (await wrapped.fetch(makeEnvelopeRequest(TUNNEL_URL))).text()).toBe('forwarded'); + }); + + it('wraps an entry whose prototype is frozen', async () => { + setClient({}); + class Handler { + public fetch(_request: Request): Response { + return new Response('app'); + } + } + Object.freeze(Handler.prototype); + + const wrapped = wrapServerEntryWithSentryTunnel(new Handler()); + + expect(await (await wrapped.fetch(makeEnvelopeRequest(TUNNEL_URL))).text()).toBe('forwarded'); + }); + + it('returns a class entry, like a `WorkerEntrypoint`, unchanged', () => { + class Worker { + public fetch(): Response { + return new Response('app'); + } + } + + expect(wrapServerEntryWithSentryTunnel(Worker as unknown as { fetch: () => Response })).toBe(Worker); + }); + + it('enables `tunnelRoute` on the client like `serveTunnelIntegration` would, dropping its spans from the start', () => { + setClient(undefined); + const options = getCurrentScope().getClient()!.getOptions(); + + wrapServerEntryWithSentryTunnel({ fetch: appFetch }, {}); + + expect(options._managedTunnel).toEqual({}); + expect( + (options.ignoreSpans![0] as { attributes: { 'url.path': RegExp } }).attributes['url.path'].test( + getTunnelPath(DSN)!, + ), + ).toBe(true); + }); + + it('keeps the options of `serveTunnelIntegration` over `tunnelRoute`', async () => { + setClient({ path: '/from-init' }); + const options = getCurrentScope().getClient()!.getOptions(); + + const response = await wrapServerEntryWithSentryTunnel({ fetch: appFetch }, { path: '/from-build' }).fetch( + makeEnvelopeRequest('http://localhost:3000/from-init'), + ); + + expect(options._managedTunnel).toEqual({ path: '/from-init' }); + expect(await response.text()).toBe('forwarded'); + }); + + it('answers the tunnel under the base path Start routes on, and only there', async () => { + setClient({}); + const wrapped = wrapServerEntryWithSentryTunnel({ fetch: appFetch }, undefined, '/app'); + + expect( + await (await wrapped.fetch(makeEnvelopeRequest(`http://localhost:3000/app${getTunnelPath(DSN)}`))).text(), + ).toBe('forwarded'); + expect(await (await wrapped.fetch(makeEnvelopeRequest(TUNNEL_URL))).text()).toBe('app'); + }); + + it('keeps a base path `serveTunnelIntegration` set', async () => { + setClient({ basePath: '/custom' }); + const wrapped = wrapServerEntryWithSentryTunnel({ fetch: appFetch }, undefined, '/app'); + + expect( + await (await wrapped.fetch(makeEnvelopeRequest(`http://localhost:3000/custom${getTunnelPath(DSN)}`))).text(), + ).toBe('forwarded'); + }); +}); diff --git a/packages/tanstackstart-react/test/vite/nitroSourceMaps.test.ts b/packages/tanstackstart-react/test/vite/nitroSourceMaps.test.ts new file mode 100644 index 000000000000..c3deb2e93e57 --- /dev/null +++ b/packages/tanstackstart-react/test/vite/nitroSourceMaps.test.ts @@ -0,0 +1,209 @@ +// @vitest-environment node +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { UserConfig } from 'vite'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + isNitroService, + makeComposeNitroServiceSourceMapsPlugin, + makeEnableNitroSourceMapsPlugin, + type PluginEnvironment, +} from '../../src/vite/nitroSourceMaps'; + +const SSR_OUT_DIR = 'node_modules/.nitro/vite/services/ssr'; + +function createEnvironment(name: string, consumer: 'client' | 'server', environmentNames: string[]): PluginEnvironment { + const environments = Object.fromEntries(environmentNames.map(environmentName => [environmentName, {}])); + return { name, config: { consumer, environments } } as unknown as PluginEnvironment; +} + +describe('isNitroService', () => { + it.each([ + { + label: 'the ssr environment of a Nitro build', + name: 'ssr', + consumer: 'server', + environments: ['client', 'ssr', 'nitro'], + expected: true, + }, + { + label: 'the nitro environment', + name: 'nitro', + consumer: 'server', + environments: ['client', 'ssr', 'nitro'], + expected: false, + }, + { + label: 'the client environment', + name: 'client', + consumer: 'client', + environments: ['client', 'ssr', 'nitro'], + expected: false, + }, + { + label: 'the ssr environment without Nitro', + name: 'ssr', + consumer: 'server', + environments: ['client', 'ssr'], + expected: false, + }, + ] as const)('returns $expected for $label', ({ name, consumer, environments, expected }) => { + expect(isNitroService(createEnvironment(name, consumer, [...environments]))).toBe(expected); + }); +}); + +describe('Nitro source map config', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + type NitroConfig = { + sourcemap?: unknown; + rollupConfig?: { output?: { sourcemap?: unknown } }; + rolldownConfig?: { output?: { sourcemap?: unknown } }; + }; + + /** Runs the Nitro module with the config sources that Nitro merged, e.g. the `nitro()` options and `nitro.config.ts`. */ + function setup( + layers: NitroConfig[] | undefined, + { debug, viteConfig = {} }: { debug?: boolean; viteConfig?: UserConfig } = {}, + ) { + const plugin = makeEnableNitroSourceMapsPlugin({ debug }); + (plugin.config as (config: UserConfig) => void)(viteConfig); + const nitro = { + options: { sourcemap: false, experimental: {}, _c12: layers && { layers: layers.map(config => ({ config })) } }, + }; + plugin.nitro.setup(nitro); + + return { plugin, nitroOptions: { sourcemap: nitro.options.sourcemap, experimental: nitro.options.experimental } }; + } + + it('enables hidden source maps with their sources when no config source sets them', () => { + const { plugin, nitroOptions } = setup([{}, {}]); + + expect(nitroOptions).toEqual({ sourcemap: 'hidden', experimental: { sourcemapMinify: false } }); + expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(true); + expect(plugin.api.hasSentryEnabledSourceMaps('client')).toBe(false); + }); + + it.each([false, true, 'hidden'])( + 'keeps the setting `sourcemap: %s` of a config source, with the sources', + sourcemap => { + const { plugin, nitroOptions } = setup([{}, { sourcemap }]); + + expect(nitroOptions).toEqual({ sourcemap: false, experimental: { sourcemapMinify: false } }); + expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(false); + }, + ); + + it.each([ + { label: '`rollupConfig.output.sourcemap`', layer: { rollupConfig: { output: { sourcemap: true } } } }, + { label: '`rolldownConfig.output.sourcemap`', layer: { rolldownConfig: { output: { sourcemap: false } } } }, + ])('keeps the source maps when a config source sets $label', ({ layer }) => { + const { plugin, nitroOptions } = setup([{}, layer]); + + expect(nitroOptions).toEqual({ sourcemap: false, experimental: { sourcemapMinify: false } }); + expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(false); + }); + + it.each([ + { label: '`build.sourcemap: false`', build: { sourcemap: false } }, + { label: '`build.rolldownOptions.output.sourcemap`', build: { rolldownOptions: { output: { sourcemap: true } } } }, + ])('keeps the source maps when the Vite config sets $label', ({ build }) => { + const { plugin, nitroOptions } = setup([{}], { viteConfig: { build } }); + + expect(nitroOptions).toEqual({ sourcemap: false, experimental: { sourcemapMinify: false } }); + expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(false); + }); + + it('enables source maps when the Vite config sets `build.sourcemap: true`, which Nitro overrides', () => { + const { plugin, nitroOptions } = setup([{}], { viteConfig: { build: { sourcemap: true } } }); + + expect(nitroOptions).toEqual({ sourcemap: 'hidden', experimental: { sourcemapMinify: false } }); + expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(true); + }); + + it('does not enable source maps when Nitro does not expose its config sources', () => { + const { plugin, nitroOptions } = setup(undefined); + + expect(nitroOptions).toEqual({ sourcemap: false, experimental: { sourcemapMinify: false } }); + expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(false); + }); + + it('logs that it enabled source maps in debug mode', () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + setup([], { debug: true }); + + expect(consoleLogSpy).toHaveBeenCalledWith( + "[Sentry] Enabled source map generation for the Nitro server build with `nitro.sourcemap: 'hidden'`.", + ); + }); +}); + +describe('Nitro service source maps', () => { + let rootDir: string; + + beforeEach(() => { + rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-tanstackstart-nitro-')); + fs.mkdirSync(path.join(rootDir, SSR_OUT_DIR, 'assets'), { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(rootDir, { recursive: true, force: true }); + }); + + function writeFile(relativePath: string, contents: string): string { + const file = path.join(rootDir, relativePath); + fs.writeFileSync(file, contents); + return file; + } + + function load(id: string): Promise { + const plugin = makeComposeNitroServiceSourceMapsPlugin(); + const context = { + environment: { + config: { + root: rootDir, + environments: { + client: { consumer: 'client', build: { outDir: '.output/public' } }, + ssr: { consumer: 'server', build: { outDir: SSR_OUT_DIR } }, + nitro: { consumer: 'server', build: { outDir: '.output/server' } }, + }, + }, + }, + }; + return (plugin.load as unknown as (this: typeof context, id: string) => Promise).call(context, id); + } + + it('applies only to the nitro environment', () => { + const plugin = makeComposeNitroServiceSourceMapsPlugin(); + const applyToEnvironment = plugin.applyToEnvironment as (environment: PluginEnvironment) => boolean; + + expect(applyToEnvironment(createEnvironment('nitro', 'server', ['ssr', 'nitro']))).toBe(true); + expect(applyToEnvironment(createEnvironment('ssr', 'server', ['ssr', 'nitro']))).toBe(false); + }); + + it('loads a service chunk together with its source map', async () => { + const map = '{"version":3,"sources":["../../../../../src/routes/index.tsx"],"mappings":"AAAA"}'; + const chunk = writeFile(`${SSR_OUT_DIR}/assets/index-BKeMB_FB.js`, 'export const handler = () => {};'); + writeFile(`${SSR_OUT_DIR}/assets/index-BKeMB_FB.js.map`, map); + + await expect(load(chunk)).resolves.toEqual({ code: 'export const handler = () => {};', map }); + }); + + it('leaves a service chunk without a source map to Nitro', async () => { + const chunk = writeFile(`${SSR_OUT_DIR}/assets/empty-plugin-adapters-BJX69BzA.js`, 'export {};'); + + await expect(load(chunk)).resolves.toBeNull(); + }); + + it('leaves modules outside the service output to Nitro', async () => { + fs.mkdirSync(path.join(rootDir, 'src'), { recursive: true }); + const module = writeFile('src/server.ts', 'export default {};'); + writeFile('src/server.ts.map', '{"version":3,"sources":[],"mappings":""}'); + + await expect(load(module)).resolves.toBeNull(); + }); +}); diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts index 0715873cdfa5..e757cd9d6173 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -2,7 +2,7 @@ import type { Plugin } from 'vite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { makeAutoInstrumentMiddlewarePlugin } from '../../src/vite/autoInstrumentMiddleware'; import { sentryTanstackStart, type SentryTanstackStartOptions } from '../../src/vite/sentryTanstackStart'; -import { makeTunnelRoutePlugin } from '../../src/vite/tunnelRoute'; +import { makeServerEntryTunnelPlugin, makeTunnelRoutePlugin } from '../../src/vite/tunnelRoute'; const mockSourceMapsConfigPlugin: Plugin = { name: 'sentry-tanstackstart-files-to-delete-after-upload-plugin', @@ -23,6 +23,17 @@ const mockEnableSourceMapsPlugin: Plugin = { config: vi.fn(), }; +const mockComposeNitroServiceSourceMapsPlugin: Plugin = { + name: 'sentry-tanstackstart-nitro-service-source-maps', + apply: 'build', + load: vi.fn(), +}; + +const mockEnableNitroSourceMapsPlugin: Plugin = { + name: 'sentry-tanstackstart-nitro-source-maps', + apply: 'build', +}; + const mockMiddlewarePlugin: Plugin = { name: 'sentry-tanstack-middleware-auto-instrument', apply: 'build', @@ -35,6 +46,16 @@ const mockTunnelRoutePlugin: Plugin = { transform: vi.fn(), }; +const mockServerEntryTunnelPlugin: Plugin = { + name: 'sentry-tanstackstart-server-entry-tunnel', + enforce: 'pre', + load: vi.fn(), +}; + +const mockTunnelBasePathPlugin: Plugin = { + name: 'sentry-tanstackstart-tunnel-base-path', +}; + const mockRoutePatternPlugin: Plugin = { name: 'sentry-tanstackstart-route-patterns', enforce: 'pre', @@ -61,12 +82,19 @@ vi.mock('../../src/vite/sourceMaps', () => ({ makeEnableSourceMapsVitePlugin: vi.fn(() => [mockEnableSourceMapsPlugin]), })); +vi.mock('../../src/vite/nitroSourceMaps', () => ({ + makeComposeNitroServiceSourceMapsPlugin: vi.fn(() => mockComposeNitroServiceSourceMapsPlugin), + makeEnableNitroSourceMapsPlugin: vi.fn(() => mockEnableNitroSourceMapsPlugin), +})); + vi.mock('../../src/vite/autoInstrumentMiddleware', () => ({ makeAutoInstrumentMiddlewarePlugin: vi.fn(() => mockMiddlewarePlugin), })); vi.mock('../../src/vite/tunnelRoute', () => ({ makeTunnelRoutePlugin: vi.fn(() => mockTunnelRoutePlugin), + makeServerEntryTunnelPlugin: vi.fn(() => mockServerEntryTunnelPlugin), + makeTunnelBasePathPlugin: vi.fn(() => ({ plugin: mockTunnelBasePathPlugin, getBasePath: () => '' })), })); describe('sentryTanstackStart()', () => { @@ -85,10 +113,14 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, mockEnableSourceMapsPlugin, + mockEnableNitroSourceMapsPlugin, ]); }); @@ -97,7 +129,7 @@ describe('sentryTanstackStart()', () => { const plugins = sentryTanstackStart({ autoInstrumentMiddleware: false }); - expect(plugins).toEqual([mockRoutePatternPlugin]); + expect(plugins).toEqual([mockRoutePatternPlugin, mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin]); }); it('returns only the tunnel route plugin in development mode when tunnelRoute is configured', () => { @@ -108,7 +140,12 @@ describe('sentryTanstackStart()', () => { tunnelRoute: { allowedDsns: ['https://public@o0.ingest.sentry.io/0'] }, }); - expect(plugins).toEqual([mockRoutePatternPlugin, mockTunnelRoutePlugin]); + expect(plugins).toEqual([ + mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, + mockTunnelRoutePlugin, + ]); }); it('returns Sentry Vite plugins but not enable source maps plugin when sourcemaps.disable is true', () => { @@ -119,9 +156,12 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, ]); }); @@ -133,9 +173,12 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, ]); }); @@ -147,10 +190,14 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, mockEnableSourceMapsPlugin, + mockEnableNitroSourceMapsPlugin, ]); }); }); @@ -161,9 +208,12 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, mockMiddlewarePlugin, ]); }); @@ -176,9 +226,12 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, mockMiddlewarePlugin, ]); }); @@ -191,9 +244,12 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, ]); }); @@ -222,10 +278,13 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, mockTunnelRoutePlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, mockMiddlewarePlugin, ]); }); @@ -242,6 +301,7 @@ describe('sentryTanstackStart()', () => { sentryTanstackStart(options); expect(makeTunnelRoutePlugin).toHaveBeenCalledWith(options.tunnelRoute, undefined); + expect(makeServerEntryTunnelPlugin).toHaveBeenCalledWith(options.tunnelRoute, expect.any(Function)); }); }); diff --git a/packages/tanstackstart-react/test/vite/sourceMapScenarios.test.ts b/packages/tanstackstart-react/test/vite/sourceMapScenarios.test.ts new file mode 100644 index 000000000000..7effcf3012f7 --- /dev/null +++ b/packages/tanstackstart-react/test/vite/sourceMapScenarios.test.ts @@ -0,0 +1,189 @@ +// @vitest-environment node +import type { SentryVitePluginOptions } from '@sentry/bundler-plugins/vite'; +import type { BuildEnvironmentOptions, Plugin, ResolvedConfig, UserConfig } from 'vite'; +import { mergeConfig } from 'vite'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { sentryTanstackStart } from '../../src/vite/sentryTanstackStart'; + +let sentryVitePluginOptions: SentryVitePluginOptions | undefined; + +vi.mock('@sentry/bundler-plugins/vite', () => ({ + sentryVitePlugin: (options: SentryVitePluginOptions) => { + sentryVitePluginOptions = options; + return []; + }, +})); + +type SourceMapSetting = boolean | 'hidden' | 'inline' | undefined; + +type NitroOptions = { + sourcemap?: SourceMapSetting; + rollupConfig?: { output?: { sourcemap?: SourceMapSetting } }; + rolldownConfig?: { output?: { sourcemap?: SourceMapSetting } }; +}; + +type NitroModulePlugin = Plugin & { nitro: { setup(nitro: unknown): void } }; + +const ENVIRONMENT_NAMES = ['client', 'ssr', 'nitro'] as const; + +function findPlugin(plugins: Plugin[], name: string): T { + const plugin = plugins.find(plugin => plugin.name === name); + expect(plugin).toBeDefined(); + return plugin as T; +} + +function callConfigHook(plugin: Plugin, config: UserConfig): UserConfig { + return (plugin.config as (config: UserConfig) => UserConfig | undefined)(config) ?? {}; +} + +/** The source map setting that applies to the output of an environment. */ +function getOutputSourceMapSetting(build: BuildEnvironmentOptions): unknown { + const output = build.rolldownOptions?.output; + return (Array.isArray(output) ? undefined : output?.sourcemap) ?? build.sourcemap; +} + +/** + * Runs Sentry's source map plugins in the order of a build with `nitro/vite`, which configures Nitro between the + * `config` hooks of the Sentry plugins with `enforce: 'pre'` and `enforce: 'post'`. + */ +async function build({ vite = {}, nitro: nitroOptions = {} }: { vite?: UserConfig; nitro?: NitroOptions }) { + const plugins = sentryTanstackStart({}); + const nitroPlugin = findPlugin(plugins, 'sentry-tanstackstart-nitro-source-maps'); + + const viteConfig = structuredClone(vite); + callConfigHook(nitroPlugin, viteConfig); + + const nitro = { + options: { + sourcemap: nitroOptions.sourcemap ?? false, + experimental: {}, + _c12: { layers: [{ config: nitroOptions }] }, + }, + }; + nitroPlugin.nitro.setup(nitro); + + const nitroOutput = { ...nitroOptions.rollupConfig?.output, ...nitroOptions.rolldownConfig?.output }; + const configWithNitro: UserConfig = mergeConfig(viteConfig, { + environments: { + client: { build: { outDir: '.output/public' } }, + ssr: { build: { sourcemap: nitro.options.sourcemap, outDir: 'node_modules/.nitro/vite/services/ssr' } }, + nitro: { + build: { + sourcemap: nitro.options.sourcemap, + rolldownOptions: { output: { ...nitroOutput, dir: '.output/server' } }, + }, + }, + }, + }); + const userConfig: UserConfig = mergeConfig( + configWithNitro, + callConfigHook(findPlugin(plugins, 'sentry-tanstackstart-react-source-maps'), configWithNitro), + ); + + const environments = Object.fromEntries( + ENVIRONMENT_NAMES.map(name => [ + name, + { + consumer: name === 'client' ? 'client' : 'server', + build: mergeConfig({ outDir: 'dist', ...userConfig.build }, userConfig.environments?.[name]?.build ?? {}), + }, + ]), + ); + const resolvedConfig = { root: process.cwd(), plugins, environments } as unknown as ResolvedConfig; + const deletionPlugin = findPlugin(plugins, 'sentry-tanstackstart-files-to-delete-after-upload-plugin'); + (deletionPlugin.configResolved as (this: unknown, config: ResolvedConfig) => void).call( + { meta: { viteVersion: '8.3.4' } }, + resolvedConfig, + ); + + return { + client: getOutputSourceMapSetting(environments.client!.build), + ssr: getOutputSourceMapSetting(environments.ssr!.build), + nitro: getOutputSourceMapSetting(environments.nitro!.build), + filesToDelete: await sentryVitePluginOptions?.sourcemaps?.filesToDeleteAfterUpload, + }; +} + +const CLIENT_MAPS = '.output/public/**/*.map'; +const SERVER_MAPS = '.output/server/**/*.map'; + +describe('source map scenarios of a build with Nitro', () => { + beforeEach(() => { + sentryVitePluginOptions = undefined; + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each<{ label: string; vite?: UserConfig; nitro?: NitroOptions; expected: Awaited> }>([ + { + label: 'nothing', + expected: { client: 'hidden', ssr: 'hidden', nitro: 'hidden', filesToDelete: [CLIENT_MAPS, SERVER_MAPS] }, + }, + { + label: 'nitro({ sourcemap: true })', + nitro: { sourcemap: true }, + expected: { client: 'hidden', ssr: true, nitro: true, filesToDelete: [CLIENT_MAPS] }, + }, + { + label: 'nitro({ sourcemap: false })', + nitro: { sourcemap: false }, + expected: { client: 'hidden', ssr: false, nitro: false, filesToDelete: [CLIENT_MAPS] }, + }, + { + label: 'nitro({ rollupConfig: { output: { sourcemap: true } } })', + nitro: { rollupConfig: { output: { sourcemap: true } } }, + expected: { client: 'hidden', ssr: false, nitro: true, filesToDelete: [CLIENT_MAPS] }, + }, + { + label: 'nitro({ rolldownConfig: { output: { sourcemap: true } } })', + nitro: { rolldownConfig: { output: { sourcemap: true } } }, + expected: { client: 'hidden', ssr: false, nitro: true, filesToDelete: [CLIENT_MAPS] }, + }, + { + label: 'build.sourcemap: true', + vite: { build: { sourcemap: true } }, + expected: { client: true, ssr: 'hidden', nitro: 'hidden', filesToDelete: [SERVER_MAPS] }, + }, + { + label: "build.sourcemap: 'hidden'", + vite: { build: { sourcemap: 'hidden' } }, + expected: { client: 'hidden', ssr: 'hidden', nitro: 'hidden', filesToDelete: [SERVER_MAPS] }, + }, + { + label: 'build.sourcemap: false', + vite: { build: { sourcemap: false } }, + expected: { client: false, ssr: false, nitro: false, filesToDelete: [] }, + }, + { + label: 'environments.client.build.sourcemap: true', + vite: { environments: { client: { build: { sourcemap: true } } } }, + expected: { client: true, ssr: 'hidden', nitro: 'hidden', filesToDelete: [SERVER_MAPS] }, + }, + { + label: 'environments.client.build.sourcemap: false', + vite: { environments: { client: { build: { sourcemap: false } } } }, + expected: { client: false, ssr: 'hidden', nitro: 'hidden', filesToDelete: [SERVER_MAPS] }, + }, + { + label: 'build.rolldownOptions.output.sourcemap: true', + vite: { build: { rolldownOptions: { output: { sourcemap: true } } } }, + expected: { client: true, ssr: true, nitro: true, filesToDelete: [] }, + }, + { + label: 'build.rolldownOptions.output.sourcemap: false', + vite: { build: { rolldownOptions: { output: { sourcemap: false } } } }, + expected: { client: false, ssr: false, nitro: false, filesToDelete: [] }, + }, + { + label: 'environments.client.build.rolldownOptions.output.sourcemap: true', + vite: { environments: { client: { build: { rolldownOptions: { output: { sourcemap: true } } } } } }, + expected: { client: true, ssr: 'hidden', nitro: 'hidden', filesToDelete: [SERVER_MAPS] }, + }, + ])('keeps the source maps of the user and deletes the others when the user sets $label', async scenario => { + await expect(build(scenario)).resolves.toEqual(scenario.expected); + }); +}); diff --git a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts index 0f41d8d261ef..12ccae09beb2 100644 --- a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts +++ b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts @@ -210,6 +210,23 @@ describe('makeAddSentryVitePlugin()', () => { expect(typeof configPlugin?.configResolved).toBe('function'); }); + it.each([ + { name: 'client', consumer: 'client', expected: true }, + { name: 'ssr', consumer: 'server', expected: false }, + { name: 'nitro', consumer: 'server', expected: true }, + ] as const)( + 'applies the Sentry Vite plugin to the $name environment of a Nitro build: $expected', + ({ name, consumer, expected }) => { + const sentryPlugin = makeAddSentryVitePlugin({}).find(plugin => plugin.name === mockedSentryVitePlugin.name); + const environment = { name, config: { consumer, environments: { client: {}, ssr: {}, nitro: {} } } }; + + expect(sentryPlugin).toBeDefined(); + expect((sentryPlugin?.applyToEnvironment as unknown as (env: typeof environment) => boolean)(environment)).toBe( + expected, + ); + }, + ); + describe('default filesToDeleteAfterUpload', () => { type TestEnvironment = { consumer: 'client' | 'server'; @@ -220,6 +237,7 @@ describe('makeAddSentryVitePlugin()', () => { const NITRO_ENVIRONMENTS: Record = { client: { consumer: 'client', outDir: '.output/public' }, + ssr: { consumer: 'server', outDir: 'node_modules/.nitro/vite/services/ssr' }, // Nitro sets the output directory of its server build with `output.dir` only. nitro: { consumer: 'server', outDir: 'dist', outputDir: '.output/server' }, }; @@ -270,7 +288,7 @@ describe('makeAddSentryVitePlugin()', () => { it.each([ { - label: 'a Nitro build', + label: 'a Nitro build, but not in the Nitro service output', environments: NITRO_ENVIRONMENTS, expected: ['.output/public/**/*.map', '.output/server/**/*.map'], }, diff --git a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts index 22b3a9be56a5..2d895e67f249 100644 --- a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts @@ -1,62 +1,26 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { vi } from 'vitest'; -import { injectManagedTunnelRoute, makeTunnelRoutePlugin, resolveTunnelRoute } from '../../src/vite/tunnelRoute'; - -const ROUTE_TREE_SOURCE = `import { Route as rootRouteImport } from './routes/__root' -import { Route as IndexRouteImport } from './routes/index' - -const IndexRoute = IndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => rootRouteImport, -} as any) - -export interface FileRoutesByFullPath { - '/': typeof IndexRoute -} -export interface RootRouteChildren { - IndexRoute: typeof IndexRoute -} - -const rootRouteChildren: RootRouteChildren = { - IndexRoute: IndexRoute, -} -export const routeTree = rootRouteImport - ._addFileChildren(rootRouteChildren) -`; - -const UNTYPED_ROUTE_TREE_SOURCE = ROUTE_TREE_SOURCE.replace( - 'const rootRouteChildren: RootRouteChildren = {', - 'const rootRouteChildren = {', -); +import type { Client } from '@sentry/core'; +import { getCurrentScope, getTunnelPath, makeDsn } from '@sentry/core'; +import { _INTERNAL_resetManagedTunnelDefaults, serveTunnelIntegration } from '@sentry/core/server'; +import type { Plugin, ResolvedConfig, UserConfig } from 'vite'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + makeServerEntryTunnelPlugin, + makeTunnelBasePathPlugin, + makeTunnelRoutePlugin, + normalizeTunnelRouteOptions, +} from '../../src/vite/tunnelRoute'; describe('tunnelRoute vite plugin', () => { - beforeEach(() => { - delete process.env.__SENTRY_INTERNAL_TANSTACKSTART_TUNNEL_ROUTE__; - }); - - afterEach(() => { - delete process.env.__SENTRY_INTERNAL_TANSTACKSTART_TUNNEL_ROUTE__; - }); - - it('reuses the same generated tunnel route within one process', () => { - const firstTunnelRoute = resolveTunnelRoute(true); - const secondTunnelRoute = resolveTunnelRoute(true); - - expect(firstTunnelRoute).toBe(secondTunnelRoute); - expect(firstTunnelRoute).toMatch(/^\/[a-z0-9]{8}$/); - }); - - it('always generates an 8-character tunnel route', () => { - vi.spyOn(Math, 'random').mockReturnValue(0.5); - - expect(resolveTunnelRoute(true)).toBe('/iiiiiiii'); - }); - - it('returns the provided static tunnel route without reusing a generated one', () => { - resolveTunnelRoute(true); - - expect(resolveTunnelRoute('/monitor')).toBe('/monitor'); + it('maps `tunnelRoute` onto the `tunnel` init options', () => { + expect(normalizeTunnelRouteOptions(true)).toEqual({}); + expect(normalizeTunnelRouteOptions('/monitor')).toEqual({ path: '/monitor' }); + expect(normalizeTunnelRouteOptions({ path: '', allowedDsns: [] })).toEqual({ + path: undefined, + allowedDsns: undefined, + }); + expect( + normalizeTunnelRouteOptions({ path: '/monitor', allowedDsns: ['https://public@o0.ingest.sentry.io/0'] }), + ).toEqual({ path: '/monitor', allowedDsns: ['https://public@o0.ingest.sentry.io/0'] }); }); it('rejects invalid static tunnel routes', () => { @@ -71,89 +35,185 @@ describe('tunnelRoute vite plugin', () => { ); }); - it('injects the managed tunnel route into the generated TanStack route tree', () => { - const transformedRouteTree = injectManagedTunnelRoute(ROUTE_TREE_SOURCE, '/monitor'); + it('tells the client the static path, or that the path comes from the DSN', () => { + const staticPlugin = makeTunnelRoutePlugin({ path: '/monitor' }); + const dsnPlugin = makeTunnelRoutePlugin(true); - expect(transformedRouteTree).toContain( - "import { Route as SentryManagedTunnelRouteImport } from 'virtual:sentry-tanstackstart-react/tunnel-route'", - ); - expect(transformedRouteTree).toContain('const SentryManagedTunnelRoute = SentryManagedTunnelRouteImport.update({'); - expect(transformedRouteTree).toContain('id: "/monitor"'); - expect(transformedRouteTree).toContain('path: "/monitor"'); - expect(transformedRouteTree).toContain('SentryManagedTunnelRoute: SentryManagedTunnelRoute,'); - expect(transformedRouteTree).toContain('IndexRoute: IndexRoute,'); + expect(staticPlugin.config && staticPlugin.config()).toEqual({ + define: { __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: '"/monitor"' }, + }); + expect(dsnPlugin.config && dsnPlugin.config()).toEqual({ + define: { __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: 'true' }, + }); }); - it('injects the managed tunnel route when rootRouteChildren is untyped', () => { - const transformedRouteTree = injectManagedTunnelRoute(UNTYPED_ROUTE_TREE_SOURCE, '/monitor'); + it('fails when a static tunnel route conflicts with an existing route', () => { + const transform = (path: string, source: string): unknown => + (makeTunnelRoutePlugin(path).transform as (source: string, id: string) => unknown)( + source, + '/app/src/routeTree.gen.ts', + ); + const singleQuoted = "const MonitorRoute = MonitorRouteImport.update({\n id: '/monitor',\n path: '/monitor',"; + const doubleQuoted = singleQuoted.replace(/'/g, '"'); - expect(transformedRouteTree).toContain('const rootRouteChildren = {'); - expect(transformedRouteTree).toContain('SentryManagedTunnelRoute: SentryManagedTunnelRoute,'); + expect(() => transform('/monitor', singleQuoted)).toThrow( + 'Cannot register managed tunnel route "/monitor" because an existing TanStack Start route already uses that path.', + ); + expect(() => transform('/monitor', doubleQuoted)).toThrow('already uses that path'); + expect(transform('/other', singleQuoted)).toBeNull(); + expect( + (makeTunnelRoutePlugin(true).transform as (s: string, id: string) => unknown)( + singleQuoted, + '/app/src/routeTree.gen.ts', + ), + ).toBeNull(); }); +}); - it('fails when the managed tunnel route conflicts with an existing route', () => { - expect(() => injectManagedTunnelRoute(ROUTE_TREE_SOURCE, '/')).toThrow( - 'Cannot register managed tunnel route "/" because an existing TanStack Start route already uses that path.', +describe('server entry tunnel vite plugin', () => { + const USER_ENTRY = '/app/src/server.ts'; + + const SDK_ID = '/app/node_modules/@sentry/tanstackstart-react/build/esm/index.server.js'; + + function setup( + tunnelRoute?: Parameters[0], + basePath = '', + resolve: (source: string, importer: string) => unknown = () => Promise.resolve({ id: SDK_ID, external: false }), + ): (id: string) => Promise { + const plugin = makeServerEntryTunnelPlugin(tunnelRoute, () => basePath); + const config = { + root: '/app', + resolve: { alias: [{ find: 'virtual:tanstack-start-server-entry', replacement: USER_ENTRY }] }, + } as unknown as ResolvedConfig; + (plugin.configResolved as (config: ResolvedConfig) => void)(config); + + return id => + (plugin.load as Extract unknown>).call( + { resolve } as never, + id, + ) as Promise; + } + + it('wraps the app server entry and the default entry', async () => { + const load = setup(); + + expect(await load(USER_ENTRY)).toContain('export default wrapServerEntryWithSentryTunnel(entry, undefined, "");'); + expect(await load(`${USER_ENTRY}?sentry-tunnel-original`)).toBeNull(); + expect(await load('/app/node_modules/@tanstack/react-start/dist/default-entry/esm/server.js')).toContain( + 'wrapServerEntryWithSentryTunnel', ); + expect( + await load('C:\\app\\node_modules\\@tanstack\\react-start\\dist\\plugin\\default-entry\\server.ts'), + ).toContain('wrapServerEntryWithSentryTunnel'); + expect(await load('/app/src/routes/index.tsx')).toBeNull(); + }); + + it('re-exports everything from the original server entry', async () => { + const load = setup(); + + expect(await load(USER_ENTRY)).toContain(`export * from "${USER_ENTRY}?sentry-tunnel-original";`); }); - it('fails on route conflict when routeTree.gen.ts uses double quotes (tsr quoteStyle: double)', () => { - const doubleQuotedMonitorTree = ROUTE_TREE_SOURCE.replace("path: '/'", 'path: "/monitor"').replace( - "id: '/'", - 'id: "/monitor"', + it('passes the build-time options when `tunnelRoute` derives the path from the DSN', async () => { + expect(await setup(true)(USER_ENTRY)).toContain('wrapServerEntryWithSentryTunnel(entry, {}, "");'); + expect(await setup({ allowedDsns: ['https://public@o0.ingest.sentry.io/0'] })(USER_ENTRY)).toContain( + 'wrapServerEntryWithSentryTunnel(entry, {"allowedDsns":["https://public@o0.ingest.sentry.io/0"]}, "");', ); + }); - expect(() => injectManagedTunnelRoute(doubleQuotedMonitorTree, '/monitor')).toThrow( - 'Cannot register managed tunnel route "/monitor" because an existing TanStack Start route already uses that path.', + it('serves a static `tunnelRoute` path from the wrapper too', async () => { + expect(await setup('/monitor')(USER_ENTRY)).toContain( + 'wrapServerEntryWithSentryTunnel(entry, {"path":"/monitor"}, "");', ); }); - it('loads a virtual managed tunnel route module for a static tunnel path', async () => { - const plugin = makeTunnelRoutePlugin({ - allowedDsns: ['http://public@localhost:3031/1337'], - path: '/monitor', - }); + it('passes the base path Start routes on to the wrapper', async () => { + expect(await setup(undefined, '/app')(USER_ENTRY)).toContain( + 'wrapServerEntryWithSentryTunnel(entry, undefined, "/app");', + ); + }); - expect(plugin.config && plugin.config()).toEqual({ - define: { - __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: '"/monitor"', - }, - }); + it('imports a bundled SDK from where the app resolves it, which also works without hoisting', async () => { + const resolve = vi.fn(() => Promise.resolve({ id: SDK_ID, external: false })); + const load = setup(undefined, '', resolve); - expect(plugin.resolveId && plugin.resolveId('virtual:sentry-tanstackstart-react/tunnel-route')).toBe( - '\0virtual:sentry-tanstackstart-react/tunnel-route', + expect(await load('/app/node_modules/@tanstack/react-start/dist/default-entry/esm/server.js')).toContain( + `import { _INTERNAL_wrapServerEntryWithSentryTunnel as wrapServerEntryWithSentryTunnel } from "${SDK_ID}";`, ); + expect(resolve).toHaveBeenCalledWith('@sentry/tanstackstart-react', '/app/package.json', { skipSelf: true }); + }); - const virtualRouteModule = plugin.load && (await plugin.load('\0virtual:sentry-tanstackstart-react/tunnel-route')); + it('imports an external or unresolved SDK by its name, so no build path ends up in the output', async () => { + const external = setup(undefined, '', () => Promise.resolve({ id: SDK_ID, external: true })); + const unresolved = setup(undefined, '', () => Promise.resolve(null)); - expect(virtualRouteModule).toContain('createFileRoute("/monitor")'); - expect(virtualRouteModule).toContain('allowedDsns: ["http://public@localhost:3031/1337"]'); + expect(await external(USER_ENTRY)).toContain(`from "@sentry/tanstackstart-react";`); + expect(await unresolved(USER_ENTRY)).toContain(`from "@sentry/tanstackstart-react";`); }); +}); - it('omits allowedDsns from the virtual managed tunnel route module when not provided', async () => { - const plugin = makeTunnelRoutePlugin('/monitor'); +describe('tunnel base path vite plugin', () => { + function resolve(config: UserConfig): { define: unknown; basePath: string } { + const { plugin, getBasePath } = makeTunnelBasePathPlugin(); + const result = (plugin.config as (config: UserConfig) => { define: unknown })(config); - const virtualRouteModule = plugin.load && (await plugin.load('\0virtual:sentry-tanstackstart-react/tunnel-route')); + return { define: result.define, basePath: getBasePath() }; + } - expect(virtualRouteModule).toContain('createFileRoute("/monitor")'); - expect(virtualRouteModule).toContain('createSentryTunnelRoute({})'); + it('reads the base path Start defines for every environment', () => { + expect(resolve({ define: { 'process.env.TSS_ROUTER_BASEPATH': JSON.stringify('/app/') } })).toEqual({ + define: { __SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__: '"/app"' }, + basePath: '/app', + }); }); - it('rejects non-POST requests in the virtual managed tunnel route module', async () => { - const plugin = makeTunnelRoutePlugin('/monitor'); + it("falls back to Vite's `base`, ignoring an absolute one", () => { + expect(resolve({ base: '/my-app/' }).basePath).toBe('/my-app'); + expect(resolve({ base: 'https://cdn.example.com/assets/' }).basePath).toBe(''); + }); - const virtualRouteModule = plugin.load && (await plugin.load('\0virtual:sentry-tanstackstart-react/tunnel-route')); + it('serves the tunnel from the root without a base path', () => { + expect(resolve({ define: { 'process.env.TSS_ROUTER_BASEPATH': JSON.stringify('/') } }).basePath).toBe(''); + expect(resolve({}).basePath).toBe(''); + }); +}); + +describe('server entry tunnel vite plugin in `vite dev`', () => { + const DSN = 'https://public@o1.ingest.sentry.io/1337'; + + afterEach(() => { + getCurrentScope().setClient(undefined); + _INTERNAL_resetManagedTunnelDefaults(); + }); - expect(virtualRouteModule).toContain('ANY()'); - expect(virtualRouteModule).toContain("status: 405, headers: { Allow: 'POST' }"); + /** Sets a client, with `serveTunnelIntegration` set up when `managedTunnel` is given. */ + function setClient(managedTunnel: object | undefined): Record { + const options: Record = { dsn: DSN, ignoreSpans: [] as unknown[] }; + const client = { getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client; + getCurrentScope().setClient(client); + if (managedTunnel) { + serveTunnelIntegration(managedTunnel).setup!(client); + } + + return options; + } + + it("drops the dev server's spans for a `tunnelRoute` under the base path", () => { + const options = setClient(undefined); + const plugin = makeServerEntryTunnelPlugin(true, () => '/app'); + + (plugin.configureServer as () => void)(); + + const [filter] = options.ignoreSpans as { attributes: Record }[]; + expect(filter?.attributes['url.path']?.test(`/app${getTunnelPath(DSN)}`)).toBe(true); }); - it('treats an empty string `path` like omitted and uses a generated tunnel route', () => { - const plugin = makeTunnelRoutePlugin({ path: '' }); + it("serves the dev server's managed tunnel from `serveTunnelIntegration` under the base path", () => { + const options = setClient({}); + const plugin = makeServerEntryTunnelPlugin(undefined, () => '/app'); + + (plugin.configureServer as () => void)(); - const defined = plugin.config && plugin.config(); - const serialized = defined?.define?.__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__; - expect(typeof serialized).toBe('string'); - expect(serialized).toMatch(/^"\/[a-z0-9]{8}"$/); + expect(options._managedTunnel).toEqual({ basePath: '/app' }); }); }); diff --git a/packages/typescript/README.md b/packages/typescript/README.md index 860302047ad1..032f3b1225e2 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -1,6 +1,10 @@

- Sentry + + + + Sentry +

diff --git a/packages/typescript/package.json b/packages/typescript/package.json index 840abb140dad..2ca483836d82 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -18,8 +18,5 @@ "scripts": { "clean": "pnpm exec rimraf sentry-typescript-*.tgz", "build:tarball": "npm pack" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/packages/vercel-edge/README.md b/packages/vercel-edge/README.md index 1929209d02d5..804ae7ec3ff7 100644 --- a/packages/vercel-edge/README.md +++ b/packages/vercel-edge/README.md @@ -1,6 +1,10 @@

- Sentry + + + + Sentry +

diff --git a/packages/vercel-edge/package.json b/packages/vercel-edge/package.json index cfc44050f52f..db4deeb45f49 100644 --- a/packages/vercel-edge/package.json +++ b/packages/vercel-edge/package.json @@ -38,6 +38,7 @@ }, "devDependencies": { "@edge-runtime/types": "4.0.0", + "@sentry-internal/rollup-utils": "11.9.0", "@sentry/opentelemetry": "11.9.0" }, "scripts": { @@ -59,8 +60,5 @@ "test:watch": "vitest --watch", "yalc:publish": "yalc publish --push --sig" }, - "volta": { - "extends": "../../package.json" - }, "sideEffects": false } diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index d1f92c146338..3177c2774979 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -105,7 +105,16 @@ export { withStreamedSpan, spanStreamingIntegration, } from '@sentry/core'; -export { trpcMiddleware, wrapMcpServerWithSentry } from '@sentry/core/server'; +export { + handleTunnelRequestIfMatched, + isTunnelRequest, + serveTunnelIntegration, + trpcMiddleware, + wrapMcpServerWithSentry, +} from '@sentry/core/server'; +export type { TunnelRequestLike } from '@sentry/core/server'; +export { getTunnelPath, handleTunnelRequest } from '@sentry/core'; +export type { HandleTunnelRequestOptions, ServerTunnelOptions } from '@sentry/core'; export { openTelemetryIntegration, getOtlpTracesEndpoint, diff --git a/packages/vercel-edge/src/types.ts b/packages/vercel-edge/src/types.ts index af568bbfe4bf..f03f63260bc3 100644 --- a/packages/vercel-edge/src/types.ts +++ b/packages/vercel-edge/src/types.ts @@ -58,7 +58,13 @@ export interface BaseVercelEdgeOptions { * Configuration options for the Sentry VercelEdge SDK * @see @sentry/core Options for more information. */ -export interface VercelEdgeOptions extends Options, BaseVercelEdgeOptions {} +export interface VercelEdgeOptions extends Options, BaseVercelEdgeOptions { + /** + * A URL to send this server's own envelopes to instead of the Sentry ingest URL. To serve the managed tunnel for the + * browser, use `serveTunnelIntegration`. + */ + tunnel?: string; +} /** * Configuration options for the Sentry VercelEdge SDK Client class diff --git a/packages/vercel-edge/test/sdk.test.ts b/packages/vercel-edge/test/sdk.test.ts index f9c69cdac777..ac3c0d436a38 100644 --- a/packages/vercel-edge/test/sdk.test.ts +++ b/packages/vercel-edge/test/sdk.test.ts @@ -1,5 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; -import { close, getClient, getCurrentScope, getDefaultIntegrations, init, spanStreamingIntegration } from '../src'; +import { + close, + getClient, + getCurrentScope, + getDefaultIntegrations, + init, + spanStreamingIntegration, + serveTunnelIntegration, +} from '../src'; import { type Event, type Integration, originalConsoleMethods } from '@sentry/core'; afterEach(() => { @@ -45,6 +53,18 @@ describe('getDefaultIntegrations', () => { }); describe('init', () => { + it('serves the managed tunnel with `serveTunnelIntegration`', () => { + const browserDsn = 'https://browser@o1.ingest.sentry.io/42'; + const client = init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [serveTunnelIntegration({ allowedDsns: [browserDsn] })], + }); + + const options = client?.getOptions(); + expect(options?.tunnel).toBeUndefined(); + expect(options?._managedTunnel).toEqual({ allowedDsns: [browserDsn] }); + }); + it('adds spanStreamingIntegration by default', () => { const client = init({}); const integrations = client?.getOptions().integrations; diff --git a/packages/vercel-edge/test/tunnelExports.test.ts b/packages/vercel-edge/test/tunnelExports.test.ts new file mode 100644 index 000000000000..c6de5ddce836 --- /dev/null +++ b/packages/vercel-edge/test/tunnelExports.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import * as Sentry from '../src'; +import type { HandleTunnelRequestOptions, ServerTunnelOptions, TunnelRequestLike } from '../src'; + +describe('managed tunnel exports', () => { + it.each([ + 'serveTunnelIntegration', + 'handleTunnelRequestIfMatched', + 'isTunnelRequest', + 'getTunnelPath', + 'handleTunnelRequest', + ])('exports %s for wiring the tunnel by hand', name => { + expect(typeof (Sentry as Record)[name]).toBe('function'); + }); + + it('exports the tunnel types', () => { + const tunnelOptions: ServerTunnelOptions = { allowedDsns: [] }; + const request: TunnelRequestLike = { method: 'POST', url: '/tunnel' }; + const handleOptions: HandleTunnelRequestOptions = { request: new Request('http://localhost/tunnel') }; + + expect([tunnelOptions, request, handleOptions]).toHaveLength(3); + }); +}); diff --git a/packages/vite-plugin/README.md b/packages/vite-plugin/README.md index 95264132eb3b..ba8f7f77a24a 100644 --- a/packages/vite-plugin/README.md +++ b/packages/vite-plugin/README.md @@ -1,6 +1,10 @@

- Sentry + + + + Sentry +

diff --git a/packages/vite-plugin/package.json b/packages/vite-plugin/package.json index 3161e436bbde..3ae62f5d58ab 100644 --- a/packages/vite-plugin/package.json +++ b/packages/vite-plugin/package.json @@ -50,7 +50,7 @@ "dependencies": { "@sentry/bundler-plugins": "11.9.0" }, - "volta": { - "extends": "../../package.json" + "devDependencies": { + "@sentry-internal/rollup-utils": "11.9.0" } } diff --git a/packages/vue/README.md b/packages/vue/README.md index 12c89db9dbee..5a40d37713bc 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -1,6 +1,10 @@

- Sentry + + + + Sentry +

diff --git a/packages/vue/package.json b/packages/vue/package.json index f4ff913b5a44..260253be913d 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -46,7 +46,7 @@ "dependencies": { "@sentry/browser": "11.9.0", "@sentry/core": "11.9.0", - "@sentry/conventions": "^0.27.0" + "@sentry/conventions": "^0.29.0" }, "peerDependencies": { "@tanstack/vue-router": "^1.64.0", @@ -62,6 +62,7 @@ } }, "devDependencies": { + "@sentry-internal/rollup-utils": "11.9.0", "@tanstack/vue-router": "^1.64.0", "vue": "~3.5.44" }, @@ -86,9 +87,6 @@ "test:watch": "vitest --watch", "yalc:publish": "yalc publish --push --sig" }, - "volta": { - "extends": "../../package.json" - }, "sideEffects": false, "nx": { "targets": { diff --git a/packages/wasm/README.md b/packages/wasm/README.md index a31e43d71ce5..919aaf1f8bc0 100644 --- a/packages/wasm/README.md +++ b/packages/wasm/README.md @@ -1,6 +1,10 @@

- Sentry + + + + Sentry +

diff --git a/packages/wasm/package.json b/packages/wasm/package.json index 13abe63f8c86..9d9c981e4c51 100644 --- a/packages/wasm/package.json +++ b/packages/wasm/package.json @@ -35,6 +35,9 @@ "@sentry/browser": "11.9.0", "@sentry/core": "11.9.0" }, + "devDependencies": { + "@sentry-internal/rollup-utils": "11.9.0" + }, "scripts": { "build": "run-p build:transpile build:bundle build:types", "build:bundle": "rollup --config rollup.bundle.config.mjs", @@ -56,8 +59,5 @@ "lint:es-compatibility": "es-check es2020 ./build/{bundles,npm/cjs}/*.js && es-check es2020 ./build/npm/esm/*.js --module", "yalc:publish": "yalc publish --push --sig" }, - "volta": { - "extends": "../../package.json" - }, "sideEffects": false } diff --git a/packages/webpack-plugin/README.md b/packages/webpack-plugin/README.md index 035bc61567ae..0afcdee95fa9 100644 --- a/packages/webpack-plugin/README.md +++ b/packages/webpack-plugin/README.md @@ -1,6 +1,10 @@

- Sentry + + + + Sentry +

diff --git a/packages/webpack-plugin/package.json b/packages/webpack-plugin/package.json index 3ecc213f74e7..61a6e373ac16 100644 --- a/packages/webpack-plugin/package.json +++ b/packages/webpack-plugin/package.json @@ -55,10 +55,10 @@ "dependencies": { "@sentry/bundler-plugins": "11.9.0" }, + "devDependencies": { + "@sentry-internal/rollup-utils": "11.9.0" + }, "peerDependencies": { "webpack": ">=5.1.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 028c386b16a8..1fb1417361a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,6 +168,8 @@ overrides: '@angular-devkit/build-angular>minimatch': 5.1.9 nitropack>rollup-plugin-visualizer: ^6.0.3 +packageExtensionsChecksum: sha256-fhlie0ez1IGoAMNlEjx0m7qxdvOSi+Hp7amur8v5jaY= + importers: .: @@ -187,195 +189,15 @@ importers: '@rollup/plugin-terser': specifier: ^0.4.4 version: 0.4.4(rollup@4.62.3) - '@sentry-internal/browser-integration-tests': - specifier: workspace:* - version: link:dev-packages/browser-integration-tests - '@sentry-internal/bun-integration-tests': - specifier: workspace:* - version: link:dev-packages/bun-integration-tests - '@sentry-internal/bundler-plugin-integration-tests': - specifier: workspace:* - version: link:dev-packages/bundler-plugin-integration-tests - '@sentry-internal/bundler-tests': - specifier: workspace:* - version: link:dev-packages/bundler-tests - '@sentry-internal/clear-cache-gh-action': - specifier: workspace:* - version: link:dev-packages/clear-cache-gh-action - '@sentry-internal/cloudflare-integration-tests': - specifier: workspace:* - version: link:dev-packages/cloudflare-integration-tests - '@sentry-internal/deno-integration-tests': - specifier: workspace:* - version: link:dev-packages/deno-integration-tests - '@sentry-internal/e2e-tests': - specifier: workspace:* - version: link:dev-packages/e2e-tests - '@sentry-internal/external-contributor-gh-action': - specifier: workspace:* - version: link:dev-packages/external-contributor-gh-action - '@sentry-internal/integration-shims': - specifier: workspace:* - version: link:packages/integration-shims '@sentry-internal/node-integration-tests': specifier: workspace:* version: link:dev-packages/node-integration-tests - '@sentry-internal/optional-e2e-gh-action': - specifier: workspace:* - version: link:dev-packages/optional-e2e-gh-action - '@sentry-internal/replay-worker': - specifier: workspace:* - version: link:packages/replay-worker - '@sentry-internal/rollup-utils': - specifier: workspace:* - version: link:dev-packages/rollup-utils - '@sentry-internal/size-limit-gh-action': - specifier: workspace:* - version: link:dev-packages/size-limit-gh-action - '@sentry-internal/test-utils': - specifier: workspace:* - version: link:dev-packages/test-utils - '@sentry/angular': - specifier: workspace:* - version: link:packages/angular - '@sentry/astro': - specifier: workspace:* - version: link:packages/astro - '@sentry/aws-serverless': - specifier: workspace:* - version: link:packages/aws-serverless - '@sentry/browser': - specifier: workspace:* - version: link:packages/browser - '@sentry/browser-utils': - specifier: workspace:* - version: link:packages/browser-utils - '@sentry/bun': - specifier: workspace:* - version: link:packages/bun - '@sentry/bundler-plugins': - specifier: workspace:* - version: link:packages/bundler-plugins - '@sentry/cloudflare': - specifier: workspace:* - version: link:packages/cloudflare - '@sentry/core': - specifier: workspace:* - version: link:packages/core - '@sentry/deno': - specifier: workspace:* - version: link:packages/deno - '@sentry/effect': - specifier: workspace:* - version: link:packages/effect - '@sentry/elysia': - specifier: workspace:* - version: link:packages/elysia - '@sentry/ember': - specifier: workspace:* - version: link:packages/ember - '@sentry/esbuild-plugin': - specifier: workspace:* - version: link:packages/esbuild-plugin - '@sentry/eslint-config-sdk': - specifier: workspace:* - version: link:packages/eslint-config-sdk '@sentry/eslint-plugin-sdk': specifier: workspace:* version: link:packages/eslint-plugin-sdk - '@sentry/feedback': - specifier: workspace:* - version: link:packages/feedback - '@sentry/gatsby': - specifier: workspace:* - version: link:packages/gatsby - '@sentry/google-cloud-serverless': - specifier: workspace:* - version: link:packages/google-cloud-serverless - '@sentry/hono': - specifier: workspace:* - version: link:packages/hono - '@sentry/nestjs': - specifier: workspace:* - version: link:packages/nestjs - '@sentry/nextjs': - specifier: workspace:* - version: link:packages/nextjs - '@sentry/nitro': - specifier: workspace:* - version: link:packages/nitro - '@sentry/node': - specifier: workspace:* - version: link:packages/node - '@sentry/node-native': - specifier: workspace:* - version: link:packages/node-native - '@sentry/nuxt': - specifier: workspace:* - version: link:packages/nuxt - '@sentry/opentelemetry': - specifier: workspace:* - version: link:packages/opentelemetry - '@sentry/profiling-node': - specifier: workspace:* - version: link:packages/profiling-node - '@sentry/react': - specifier: workspace:* - version: link:packages/react - '@sentry/react-router': - specifier: workspace:* - version: link:packages/react-router - '@sentry/remix': - specifier: workspace:* - version: link:packages/remix - '@sentry/replay': - specifier: workspace:* - version: link:packages/replay-internal - '@sentry/replay-canvas': - specifier: workspace:* - version: link:packages/replay-canvas - '@sentry/rollup-plugin': - specifier: workspace:* - version: link:packages/rollup-plugin - '@sentry/server-runtime-injection': - specifier: workspace:* - version: link:packages/server-runtime-injection - '@sentry/server-utils': - specifier: workspace:* - version: link:packages/server-utils - '@sentry/solid': - specifier: workspace:* - version: link:packages/solid - '@sentry/solidstart': - specifier: workspace:* - version: link:packages/solidstart - '@sentry/svelte': - specifier: workspace:* - version: link:packages/svelte '@sentry/sveltekit': specifier: workspace:* version: link:packages/sveltekit - '@sentry/tanstackstart-react': - specifier: workspace:* - version: link:packages/tanstackstart-react - '@sentry/typescript': - specifier: workspace:* - version: link:packages/typescript - '@sentry/vercel-edge': - specifier: workspace:* - version: link:packages/vercel-edge - '@sentry/vite-plugin': - specifier: workspace:* - version: link:packages/vite-plugin - '@sentry/vue': - specifier: workspace:* - version: link:packages/vue - '@sentry/wasm': - specifier: workspace:* - version: link:packages/wasm - '@sentry/webpack-plugin': - specifier: workspace:* - version: link:packages/webpack-plugin '@size-limit/esbuild': specifier: ~12.1.0 version: 12.1.0(size-limit@12.1.0(jiti@2.7.0)) @@ -398,8 +220,8 @@ importers: specifier: ^4.2.2 version: 4.3.1 es-check: - specifier: ^7.2.1 - version: 7.2.1 + specifier: ^9.8.1 + version: 9.8.1 esbuild: specifier: ^0.28.1 version: 0.28.1 @@ -442,6 +264,9 @@ importers: size-limit: specifier: ~12.1.0 version: 12.1.0(jiti@2.7.0) + tslib: + specifier: ^2.4.1 + version: 2.8.1 tsx: specifier: ^4.23.0 version: 4.23.0 @@ -451,6 +276,9 @@ importers: vitest: specifier: ^4.1.11 version: 4.1.11(@opentelemetry/api@1.10.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.11)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@21.1.2(supports-color@10.0.0))(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0) + webpack: + specifier: ^5.104.1 + version: 5.104.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3) yalc: specifier: ^1.0.0-pre.53 version: 1.0.0-pre.53 @@ -470,8 +298,8 @@ importers: specifier: 11.9.0 version: link:../../packages/browser '@sentry/conventions': - specifier: 0.27.0 - version: 0.27.0 + specifier: 0.29.0 + version: 0.29.0 '@sentry/opentelemetry': specifier: 11.9.0 version: link:../../packages/opentelemetry @@ -479,8 +307,8 @@ importers: specifier: 11.9.0 version: link:../../packages/replay-internal '@sentry/rrweb': - specifier: 2.44.1 - version: 2.44.1 + specifier: 2.45.0 + version: 2.45.0 '@supabase/supabase-js': specifier: 2.109.0 version: 2.109.0 @@ -500,6 +328,21 @@ importers: specifier: ^5.95.0 version: 5.104.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3) devDependencies: + '@sentry/browser-utils': + specifier: 11.9.0 + version: link:../../packages/browser-utils + '@sentry/core': + specifier: 11.9.0 + version: link:../../packages/core + '@sentry/replay-canvas': + specifier: 11.9.0 + version: link:../../packages/replay-canvas + '@sentry/rrweb-types': + specifier: ^2.45.0 + version: 2.45.0 + '@sentry/wasm': + specifier: 11.9.0 + version: link:../../packages/wasm '@types/node': specifier: ^20.19.0 version: 20.19.43 @@ -520,7 +363,7 @@ importers: version: link:../../packages/hono hono: specifier: ^4.13.7 - version: 4.13.7 + version: 4.13.11 mysql: specifier: ^2.18.1 version: 2.18.1 @@ -534,6 +377,15 @@ importers: '@sentry-internal/test-utils': specifier: 11.9.0 version: link:../test-utils + '@sentry/core': + specifier: 11.9.0 + version: link:../../packages/core + '@types/mysql': + specifier: ^2.15.27 + version: 2.15.27 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 bun-types: specifier: ^1.2.9 version: 1.2.9 @@ -550,6 +402,9 @@ importers: premove: specifier: ^4.0.0 version: 4.0.0 + sentry: + specifier: ^0.45.0 + version: 0.45.0 typescript: specifier: ~6.0.3 version: 6.0.3 @@ -595,22 +450,22 @@ importers: dependencies: '@anthropic-ai/sdk': specifier: 0.133.0 - version: 0.133.0(zod@4.6.5) + version: 0.133.0(zod@4.6.7) '@google/genai': specifier: ^1.20.0 version: 1.20.0(encoding@0.1.13)(supports-color@10.0.0) '@langchain/core': specifier: ^1.2.17 - version: 1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3) + version: 1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3) '@langchain/langgraph': specifier: ^1.0.1 - version: 1.4.21(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod@4.6.5) + version: 1.4.21(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod@4.6.7) '@langchain/openai': specifier: ^1.6.2 - version: 1.6.2(@aws-sdk/credential-provider-node@3.972.84)(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))(@smithy/signature-v4@5.7.4)(undici@7.24.8)(ws@8.21.3) + version: 1.6.2(@aws-sdk/credential-provider-node@3.972.84)(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3))(@smithy/signature-v4@5.7.4)(undici@7.24.8)(ws@8.21.3) '@langchain/typesafe': specifier: ^0.0.2 - version: 0.0.2(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))(langchain@1.5.16(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(ws@8.21.3)) + version: 0.0.2(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3))(langchain@1.5.16(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3))(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(ws@8.21.3)) '@prisma/adapter-d1': specifier: 6.15.0 version: 6.15.0 @@ -628,16 +483,16 @@ importers: version: link:../../packages/hono ai: specifier: ^6.0.0 - version: 6.0.218(zod@4.6.5) + version: 6.0.218(zod@4.6.7) hono: specifier: ^4.13.7 - version: 4.13.7 + version: 4.13.11 langchain: specifier: ^1.5.16 - version: 1.5.16(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(ws@8.21.3) + version: 1.5.16(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3))(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(ws@8.21.3) openai: specifier: 5.18.1 - version: 5.18.1(ws@8.21.3)(zod@4.6.5) + version: 5.18.1(ws@8.21.3)(zod@4.6.7) devDependencies: '@cloudflare/vite-plugin': specifier: 1.34.0 @@ -648,12 +503,15 @@ importers: '@cloudflare/workers-types-v5': specifier: npm:@cloudflare/workers-types@5.20260710.1 version: '@cloudflare/workers-types@5.20260710.1' + '@opentelemetry/api': + specifier: ^1.9.1 + version: 1.10.0 '@sentry-internal/test-utils': specifier: 11.9.0 version: link:../test-utils '@sentry/conventions': - specifier: 0.27.0 - version: 0.27.0 + specifier: 0.29.0 + version: 0.29.0 eslint-plugin-regexp: specifier: ^3.1.0 version: 3.1.0 @@ -688,6 +546,9 @@ importers: '@sentry-internal/node-integration-tests': specifier: 11.9.0 version: link:../node-integration-tests + '@sentry/conventions': + specifier: ^0.29.0 + version: 0.29.0 vitest: specifier: ^4.1.11 version: 4.1.11(@opentelemetry/api@1.10.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.11)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@21.1.2(supports-color@10.0.0))(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0) @@ -715,7 +576,7 @@ importers: dependencies: '@anthropic-ai/sdk': specifier: 0.133.0 - version: 0.133.0(zod@3.25.76) + version: 0.133.0(zod@4.6.7) '@apollo/server': specifier: ^5.5.0 version: 5.5.0(graphql@16.12.0)(supports-color@10.0.0) @@ -754,22 +615,22 @@ importers: version: 21.3.10 '@hono/node-server': specifier: ^2.0.10 - version: 2.0.10(hono@4.13.7) + version: 2.0.10(hono@4.13.11) '@koa/router': specifier: ^12.0.1 version: 12.0.2(supports-color@10.0.0) '@langchain/anthropic': specifier: ^0.3.10 - version: 0.3.31(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@3.25.76)))(zod@3.25.76) + version: 0.3.31(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7)))(zod@4.6.7) '@langchain/core': specifier: ^0.3.80 - version: 0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@3.25.76)) + version: 0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7)) '@langchain/langgraph': specifier: ^0.2.32 - version: 0.2.74(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@3.25.76)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod-to-json-schema@3.25.2(zod@3.25.76)) + version: 0.2.74(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod-to-json-schema@3.25.2(zod@4.6.7)) '@langchain/openai': specifier: ^0.5.0 - version: 0.5.18(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@3.25.76)))(ws@8.21.3) + version: 0.5.18(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7)))(ws@8.21.3) '@mistralai/mistralai': specifier: 2.6.4 version: 2.6.4(@opentelemetry/api@1.9.0) @@ -806,9 +667,15 @@ importers: '@sentry/node': specifier: 11.9.0 version: link:../../packages/node + '@sentry/server-runtime-injection': + specifier: 11.9.0 + version: link:../../packages/server-runtime-injection '@sentry/server-utils': specifier: 11.9.0 version: link:../../packages/server-utils + '@smithy/node-http-handler': + specifier: 4.12.1 + version: 4.12.1 '@types/mongodb': specifier: ^3.6.20 version: 3.6.20 @@ -820,7 +687,7 @@ importers: version: 8.20.0 ai: specifier: ^4.3.16 - version: 4.3.16(react@18.3.1)(zod@3.25.76) + version: 4.3.16(react@18.3.1)(zod@4.6.7) amqplib: specifier: ^0.10.9 version: 0.10.9 @@ -843,8 +710,8 @@ importers: specifier: ^4.21.2 version: 4.22.1(supports-color@10.0.0) fastify: - specifier: ^5.12.5 - version: 5.12.5 + specifier: ^5.12.7 + version: 5.12.7 generic-pool: specifier: ^3.9.0 version: 3.9.0 @@ -862,7 +729,7 @@ importers: version: 2.0.1-rc.22(crossws@0.4.10(srvx@0.11.22)) hono: specifier: ^4.13.7 - version: 4.13.7 + version: 4.13.11 http-terminator: specifier: ^3.2.0 version: 3.2.0 @@ -913,7 +780,7 @@ importers: version: 2.1.1 openai: specifier: 5.18.1 - version: 5.18.1(ws@8.21.3)(zod@3.25.76) + version: 5.18.1(ws@8.21.3)(zod@4.6.7) pg: specifier: 8.20.0 version: 8.20.0 @@ -969,21 +836,33 @@ importers: specifier: ^16.2.0 version: 16.2.0 devDependencies: + '@hapi/boom': + specifier: ^10.0.1 + version: 10.0.1 '@neondatabase/serverless': specifier: 1.2.0 version: 1.2.0 '@opentelemetry/instrumentation-http': specifier: 0.220.0 version: 0.220.0(@opentelemetry/api@1.9.0)(supports-color@10.0.0) + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../rollup-utils '@sentry-internal/test-utils': specifier: 11.9.0 version: link:../test-utils '@sentry/conventions': - specifier: 0.27.0 - version: 0.27.0 + specifier: 0.29.0 + version: 0.29.0 + '@sentry/node-native': + specifier: 11.9.0 + version: link:../../packages/node-native '@types/amqplib': specifier: ^0.10.5 version: 0.10.5 + '@types/express': + specifier: ^4.17.21 + version: 4.17.21 '@types/node-cron': specifier: ^3.0.11 version: 3.0.11 @@ -1002,9 +881,12 @@ importers: react: specifier: ^18.3.1 version: 18.3.1 + winston-transport: + specifier: ^4.9.0 + version: 4.9.0 zod: - specifier: ^3.24.1 - version: 3.25.76 + specifier: ^4.6.7 + version: 4.6.7 dev-packages/optional-e2e-gh-action: dependencies: @@ -1070,9 +952,15 @@ importers: '@playwright/test': specifier: 1.64.1 version: 1.64.1 + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../rollup-utils '@sentry/core': specifier: 11.9.0 version: link:../../packages/core + '@types/express': + specifier: ^4.17.21 + version: 4.17.21 '@types/ws': specifier: ^8.18.1 version: 8.18.1 @@ -1086,8 +974,8 @@ importers: specifier: 11.9.0 version: link:../browser '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1147,8 +1035,8 @@ importers: specifier: 11.9.0 version: link:../bundler-plugins '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1162,6 +1050,9 @@ importers: specifier: 11.9.0 version: link:../server-utils devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils astro: specifier: ^4.16.19 version: 4.16.19(@types/node@20.19.43)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(rollup@4.62.3)(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(typescript@7.0.2) @@ -1172,8 +1063,8 @@ importers: packages/aws-serverless: dependencies: '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1190,6 +1081,9 @@ importers: specifier: ^8.10.161 version: 8.10.161 devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils '@types/node': specifier: ^20.19.0 version: 20.19.43 @@ -1203,8 +1097,8 @@ importers: specifier: 11.9.0 version: link:../browser-utils '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1221,6 +1115,9 @@ importers: '@sentry-internal/integration-shims': specifier: 11.9.0 version: link:../integration-shims + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils fake-indexeddb: specifier: ^6.2.5 version: 6.2.5 @@ -1228,20 +1125,27 @@ importers: packages/browser-utils: dependencies: '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core web-vitals: specifier: ^6.2.3 version: 6.2.3 + devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils packages/bun: dependencies: + '@sentry/bundler-plugins': + specifier: 11.9.0 + version: link:../bundler-plugins '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1252,6 +1156,9 @@ importers: specifier: 11.9.0 version: link:../server-utils devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils bun-types: specifier: ^1.2.9 version: 1.2.9 @@ -1283,9 +1190,18 @@ importers: '@babel/core': specifier: ^7.18.5 version: 7.29.7(supports-color@8.1.1) + '@babel/parser': + specifier: ^7.29.8 + version: 7.29.9 '@babel/preset-react': specifier: ^7.23.3 version: 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils + '@sentry-internal/test-utils': + specifier: 11.9.0 + version: link:../../dev-packages/test-utils '@types/babel__core': specifier: ^7.20.5 version: 7.20.5 @@ -1311,8 +1227,8 @@ importers: specifier: ^1.9.1 version: 1.10.0 '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1329,9 +1245,18 @@ importers: '@cloudflare/workers-types': specifier: 5.20260710.1 version: 5.20260710.1 + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils '@types/node': specifier: ^20.19.0 version: 20.19.43 + acorn: + specifier: ^8.14.0 + version: 8.18.0 + vite: + specifier: ^8.3.1 + version: 8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0) wrangler: specifier: 4.61.0 version: 4.61.0(@cloudflare/workers-types@5.20260710.1) @@ -1339,12 +1264,15 @@ importers: packages/core: dependencies: '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils zod: - specifier: ^3.24.1 - version: 3.25.76 + specifier: ^4.6.7 + version: 4.6.7 packages/deno: dependencies: @@ -1352,8 +1280,8 @@ importers: specifier: ^1.9.1 version: 1.10.0 '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1363,6 +1291,10 @@ importers: '@sentry/server-utils': specifier: 11.9.0 version: link:../server-utils + devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils packages/effect: dependencies: @@ -1370,8 +1302,8 @@ importers: specifier: 11.9.0 version: link:../browser '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1382,6 +1314,9 @@ importers: '@effect/vitest': specifier: ^4.0.0 version: 4.0.0(effect@4.0.0)(vitest@4.1.11) + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils effect: specifier: ^4.0.0 version: 4.0.0 @@ -1392,12 +1327,15 @@ importers: specifier: 11.9.0 version: link:../bun '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils bun-types: specifier: ^1.2.9 version: 1.2.9 @@ -1417,8 +1355,8 @@ importers: specifier: 11.9.0 version: link:../browser '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1465,12 +1403,19 @@ importers: typescript: specifier: ~5.8.0 version: 5.8.3 + vite: + specifier: ^8.3.1 + version: 8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0) packages/esbuild-plugin: dependencies: '@sentry/bundler-plugins': specifier: 11.9.0 version: link:../bundler-plugins + devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils packages/eslint-config-sdk: dependencies: @@ -1506,7 +1451,14 @@ importers: specifier: 8.57.0 version: 8.57.0(supports-color@10.0.0) - packages/eslint-plugin-sdk: {} + packages/eslint-plugin-sdk: + devDependencies: + '@types/eslint': + specifier: ^8.2.1 + version: 8.2.1 + eslint: + specifier: ^8.57.0 + version: 8.57.0(supports-color@10.0.0) packages/feedback: dependencies: @@ -1514,6 +1466,9 @@ importers: specifier: 11.9.0 version: link:../core devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils preact: specifier: ^10.29.8 version: 10.29.8 @@ -1530,6 +1485,9 @@ importers: specifier: 11.9.0 version: link:../react devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils '@testing-library/react': specifier: ^15.0.5 version: 15.0.5(@types/react@17.0.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1546,8 +1504,8 @@ importers: packages/google-cloud-serverless: dependencies: '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1567,6 +1525,9 @@ importers: '@google-cloud/common': specifier: ^3.4.1 version: 3.6.0(encoding@0.1.13)(supports-color@10.0.0) + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils '@types/express': specifier: ^5.0.6 version: 5.0.6 @@ -1583,8 +1544,8 @@ importers: specifier: ^1.9.1 version: 1.10.0 '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1595,9 +1556,30 @@ importers: '@cloudflare/workers-types': specifier: 4.20250922.0 version: 4.20250922.0 + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils + '@sentry/bun': + specifier: 11.9.0 + version: link:../bun + '@sentry/cloudflare': + specifier: 11.9.0 + version: link:../cloudflare + '@sentry/deno': + specifier: 11.9.0 + version: link:../deno + '@sentry/node': + specifier: 11.9.0 + version: link:../node '@types/node': specifier: ^20.19.0 version: 20.19.43 + hono: + specifier: 4.13.11 + version: 4.13.11 + hono-5: + specifier: npm:hono@5.0.0-rc.1 + version: hono@5.0.0-rc.1 wrangler: specifier: 4.62.0 version: 4.62.0(@cloudflare/workers-types@4.20250922.0) @@ -1607,12 +1589,16 @@ importers: '@sentry/core': specifier: 11.9.0 version: link:../core + devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils packages/nestjs: dependencies: '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1629,6 +1615,9 @@ importers: '@nestjs/core': specifier: ^10.0.0 version: 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.2))(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -1654,8 +1643,8 @@ importers: specifier: 11.9.0 version: link:../cloudflare '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1684,9 +1673,15 @@ importers: specifier: ^0.1.11 version: 0.1.11 devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils + cjs-module-lexer: + specifier: ^2.2.0 + version: 2.2.0 next: - specifier: 15.5.27 - version: 15.5.27(@babel/core@7.29.7(supports-color@10.0.0))(@opentelemetry/api@1.10.0)(@playwright/test@1.64.1)(@types/node@20.19.43)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0) + specifier: 15.5.28 + version: 15.5.28(@babel/core@7.29.7(supports-color@10.0.0))(@opentelemetry/api@1.10.0)(@playwright/test@1.64.1)(@types/node@20.19.43)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0) react: specifier: ^18.3.1 version: 18.3.1 @@ -1700,8 +1695,8 @@ importers: specifier: 11.9.0 version: link:../bundler-plugins '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1715,12 +1710,18 @@ importers: specifier: 11.9.0 version: link:../server-utils devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils h3: specifier: ^2.0.1-rc.20 version: 2.0.1-rc.22(crossws@0.4.10(srvx@0.11.22)) nitro: specifier: ^3.0.260415-beta version: 3.0.260415-beta(@azure/storage-blob@12.31.0(supports-color@10.0.0))(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.3.1)(ioredis@5.11.0(supports-color@10.0.0))(jiti@2.7.0)(lru-cache@11.4.0)(miniflare@4.20260426.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.983.0)(socks@2.8.7))(mysql2@3.23.3(@types/node@20.19.43))(rollup@4.62.3)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0)) + srvx: + specifier: ^0.11.22 + version: 0.11.22 unstorage: specifier: ^2.0.0-alpha.7 version: 2.0.0-alpha.7(@azure/storage-blob@12.31.0(supports-color@10.0.0))(chokidar@5.0.0)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.11.0(supports-color@10.0.0))(lru-cache@11.4.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.983.0)(socks@2.8.7))(ofetch@1.5.1) @@ -1734,8 +1735,8 @@ importers: specifier: 11.9.0 version: link:../bundler-plugins '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1749,6 +1750,9 @@ importers: specifier: 11.9.0 version: link:../server-utils devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils '@types/node': specifier: ^20.19.0 version: 20.19.43 @@ -1765,6 +1769,9 @@ importers: specifier: ^0.5.1 version: 0.5.1 devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils '@types/node': specifier: ^20.19.0 version: 20.19.43 @@ -1784,8 +1791,8 @@ importers: specifier: 11.9.0 version: link:../cloudflare '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1802,30 +1809,51 @@ importers: specifier: 11.9.0 version: link:../vue devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20260519.1 + version: 4.20260519.1 '@nuxt/nitro-server': specifier: ^3.21.11 version: 3.21.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(encoding@0.1.13)(esbuild@0.28.1)(ioredis@5.11.0(supports-color@10.0.0))(magicast@0.5.4)(mysql2@3.23.3(@types/node@20.19.43))(nuxt@3.21.11(0dc0d2df1a20b551c0e4dd687b74c07c))(oxc-parser@0.152.0)(rolldown@1.2.11)(rollup@4.62.3)(srvx@0.11.22)(supports-color@10.0.0)(typescript@7.0.2)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3)) + '@nuxt/schema': + specifier: ^3.21.11 + version: 3.21.11 + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils + db0: + specifier: ^0.3.4 + version: 0.3.4(mysql2@3.23.3(@types/node@20.19.43)) h3: specifier: ^1.15.10 version: 1.15.11 nitro: specifier: ^3.0.260311-beta version: 3.0.260311-beta(@azure/storage-blob@12.31.0(supports-color@10.0.0))(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.3.1)(ioredis@5.11.0(supports-color@10.0.0))(jiti@2.7.0)(lru-cache@11.4.0)(miniflare@4.20260426.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.983.0)(socks@2.8.7))(mysql2@3.23.3(@types/node@20.19.43))(rollup@4.62.3)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0)) + nitropack: + specifier: ^2.13.4 + version: 2.13.4(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(encoding@0.1.13)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(srvx@0.11.22)(supports-color@10.0.0)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3)) nuxi: specifier: ^3.25.1 version: 3.25.1 nuxt: specifier: 3.21.11 version: 3.21.11(0dc0d2df1a20b551c0e4dd687b74c07c) + unstorage: + specifier: ^1.17.5 + version: 1.17.5(@azure/storage-blob@12.31.0(supports-color@10.0.0))(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.11.0(supports-color@10.0.0)) vite: specifier: ^8.3.1 version: 8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0) + vue: + specifier: ^3.5.41 + version: 3.5.44(typescript@7.0.2) packages/opentelemetry: dependencies: '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1833,6 +1861,9 @@ importers: '@opentelemetry/api': specifier: ^1.9.1 version: 1.10.0 + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils packages/profiling-node: dependencies: @@ -1846,6 +1877,9 @@ importers: specifier: ^2.4.4 version: 2.4.4 devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils '@types/node': specifier: ^20.19.0 version: 20.19.43 @@ -1856,12 +1890,15 @@ importers: specifier: 11.9.0 version: link:../browser '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils '@testing-library/react': specifier: ^15.0.5 version: 15.0.5(@types/react@17.0.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1929,8 +1966,8 @@ importers: specifier: 11.9.0 version: link:../bundler-plugins '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1959,6 +1996,18 @@ importers: '@react-router/node': specifier: ^7.15.0 version: 7.18.1(react-router@7.18.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@6.0.3) + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils + '@testing-library/react': + specifier: ^15.0.5 + version: 15.0.5(@types/react@17.0.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/node': + specifier: ^20.19.0 + version: 20.19.43 + '@types/react': + specifier: 17.0.3 + version: 17.0.3 react: specifier: ^18.3.1 version: 18.3.1 @@ -1981,8 +2030,8 @@ importers: specifier: 11.9.0 version: link:../bundler-plugins '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -2012,20 +2061,32 @@ importers: specifier: ^2.17.5 version: 2.17.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3) '@remix-run/route-pattern': - specifier: ^0.24.0 - version: 0.24.1 + specifier: ^1.0.0 + version: 1.0.0 '@remix-run/server-runtime': specifier: ^2.17.4 version: 2.17.5(typescript@5.8.3) + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils '@types/express': specifier: ^4.17.14 version: 4.17.21 + '@types/react': + specifier: 17.0.3 + version: 17.0.3 + '@types/semver': + specifier: ^7.5.3 + version: 7.5.3 react: specifier: ^18.3.1 version: 18.3.1 react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + semver: + specifier: ^7.8.5 + version: 7.8.5 vite: specifier: ^8.3.1 version: 8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0) @@ -2039,9 +2100,12 @@ importers: specifier: 11.9.0 version: link:../replay-internal devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils '@sentry/rrweb': - specifier: 2.44.1 - version: 2.44.1 + specifier: 2.45.0 + version: 2.45.0 packages/replay-internal: dependencies: @@ -2058,12 +2122,18 @@ importers: '@sentry-internal/replay-worker': specifier: 11.9.0 version: link:../replay-worker + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils + '@sentry/conventions': + specifier: ^0.29.0 + version: 0.29.0 '@sentry/rrweb': - specifier: 2.44.1 - version: 2.44.1 + specifier: 2.45.0 + version: 2.45.0 '@sentry/rrweb-snapshot': - specifier: 2.44.1 - version: 2.44.1 + specifier: 2.45.0 + version: 2.45.0 fflate: specifier: 0.8.3 version: 0.8.3 @@ -2085,6 +2155,10 @@ importers: '@sentry/bundler-plugins': specifier: 11.9.0 version: link:../bundler-plugins + devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils packages/server-runtime-injection: dependencies: @@ -2101,6 +2175,9 @@ importers: '@apm-js-collab/tracing-hooks': specifier: ^0.13.3 version: 0.13.3(supports-color@7.2.0) + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils '@types/node': specifier: ^20.19.0 version: 20.19.43 @@ -2120,18 +2197,21 @@ importers: specifier: ^1.9.1 version: 1.10.0 '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core devDependencies: '@apm-js-collab/code-transformer-bundler-plugins': specifier: ^0.7.4 - version: 0.7.4 + version: 0.7.4(esbuild@0.28.1)(rollup@4.62.3)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.104.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3)) '@neondatabase/serverless': specifier: 1.2.0 version: 1.2.0 + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils '@types/node': specifier: ^20.19.0 version: 20.19.43 @@ -2141,6 +2221,9 @@ importers: vite: specifier: ^8.3.1 version: 8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0) + webpack: + specifier: ^5.104.1 + version: 5.104.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3) packages/solid: dependencies: @@ -2148,12 +2231,15 @@ importers: specifier: 11.9.0 version: link:../browser '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils '@solidjs/router': specifier: ^1.0.0 version: 1.0.0(solid-js@1.9.11) @@ -2188,8 +2274,8 @@ importers: specifier: 11.9.0 version: link:../bundler-plugins '@sentry/conventions': - specifier: 0.27.0 - version: 0.27.0 + specifier: 0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -2208,7 +2294,16 @@ importers: '@sentry/solid': specifier: 11.9.0 version: link:../solid + h3: + specifier: ^1.15.10 + version: 1.15.11 devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils + '@sentry/browser': + specifier: 11.9.0 + version: link:../browser '@solidjs/router': specifier: ^1.0.0 version: 1.0.0(solid-js@1.9.11) @@ -2224,6 +2319,15 @@ importers: '@testing-library/user-event': specifier: ^14.5.2 version: 14.5.2(@testing-library/dom@10.4.1) + '@types/node': + specifier: ^20.19.0 + version: 20.19.43 + nitropack: + specifier: ^2.13.4 + version: 2.13.4(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(encoding@0.1.13)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(srvx@0.11.22)(supports-color@10.0.0)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3)) + node-mock-http: + specifier: ^1.0.4 + version: 1.0.4 solid-js: specifier: ^1.9.11 version: 1.9.11 @@ -2243,8 +2347,8 @@ importers: specifier: 11.9.0 version: link:../browser '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -2252,6 +2356,9 @@ importers: specifier: ~0.30.0 version: 0.30.21 devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils '@sveltejs/vite-plugin-svelte': specifier: 1.4.0 version: 1.4.0(supports-color@10.0.0)(svelte@3.59.2)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0)) @@ -2277,8 +2384,8 @@ importers: specifier: 11.9.0 version: link:../cloudflare '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -2307,12 +2414,21 @@ importers: specifier: 1.0.0 version: 1.0.0 devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils + '@sentry/browser': + specifier: 11.9.0 + version: link:../browser '@sveltejs/kit': specifier: ^2.70.2 version: 2.70.2(@opentelemetry/api@1.10.0)(@sveltejs/vite-plugin-svelte@3.1.2(supports-color@10.0.0)(svelte@4.2.8)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0)))(svelte@4.2.8)(typescript@6.0.3)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0)) '@sveltejs/vite-plugin-svelte': specifier: ^3.0.0 version: 3.1.2(supports-color@10.0.0)(svelte@4.2.8)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0)) + '@types/node': + specifier: ^20.19.0 + version: 20.19.43 svelte: specifier: ^4.2.8 version: 4.2.8 @@ -2332,8 +2448,8 @@ importers: specifier: 11.9.0 version: link:../bundler-plugins '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -2350,6 +2466,12 @@ importers: specifier: 11.9.0 version: link:../server-utils devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils + '@types/node': + specifier: ^20.19.0 + version: 20.19.43 vite: specifier: ^8.3.1 version: 8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0) @@ -2371,6 +2493,9 @@ importers: '@edge-runtime/types': specifier: 4.0.0 version: 4.0.0 + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils '@sentry/opentelemetry': specifier: 11.9.0 version: link:../opentelemetry @@ -2380,6 +2505,10 @@ importers: '@sentry/bundler-plugins': specifier: 11.9.0 version: link:../bundler-plugins + devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils packages/vue: dependencies: @@ -2387,12 +2516,15 @@ importers: specifier: 11.9.0 version: link:../browser '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils '@tanstack/vue-router': specifier: ^1.64.0 version: 1.141.4(vue@3.5.44(typescript@7.0.2)) @@ -2408,12 +2540,20 @@ importers: '@sentry/core': specifier: 11.9.0 version: link:../core + devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils packages/webpack-plugin: dependencies: '@sentry/bundler-plugins': specifier: 11.9.0 version: link:../bundler-plugins + devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.9.0 + version: link:../../dev-packages/rollup-utils packages: @@ -2645,6 +2785,20 @@ packages: '@apm-js-collab/code-transformer-bundler-plugins@0.7.4': resolution: {integrity: sha512-nAfOeZPSUAQvJa1iFT/5oCrTm5YQhMMrfCNthNnaXHZiOQhu1KGuLoIx7HtbAi3wfwaBYLaICPIeenIaEwcXIg==} engines: {node: '>=18.0.0'} + peerDependencies: + esbuild: '*' + rollup: '*' + vite: '*' + webpack: '*' + peerDependenciesMeta: + esbuild: + optional: true + rollup: + optional: true + vite: + optional: true + webpack: + optional: true '@apm-js-collab/code-transformer@0.18.1': resolution: {integrity: sha512-u1Hb6bHjWtkSpiprwVP6YaHC1DTN4RAU3zYkUDUe7WMnJwdyU1pwTL9dFKiSJB9IiLue/EQovmyx6xhU7FFtAQ==} @@ -6042,57 +6196,57 @@ packages: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 - '@next/env@15.5.27': - resolution: {integrity: sha512-WC5hvVqiuFOcRyz6gKPeJkiVvSMf3scNeAV49CdWtVqxmUlzh8UKR6YHt9yiNkh8LngtzqSlVz07/Ma3ZAzchA==} + '@next/env@15.5.28': + resolution: {integrity: sha512-xxW5EBI8fMPuVTPBW7LL980IRbuDXlbkUAwp9DDhtb4Lnich3nJ13uYpvrX4VE74hWPf6AvHHAMmGCidcHjbfg==} - '@next/swc-darwin-arm64@15.5.27': - resolution: {integrity: sha512-fBA9QurmK4+uj4wNhmRTuO5zMVRaBHCvp6H93zTG/5i6Yi6TewbvbesXB8YQNj8T99Cwrow87hHiFV6wL/hyMA==} + '@next/swc-darwin-arm64@15.5.28': + resolution: {integrity: sha512-dPoQYXcfSIqx5o+TOXVXO7kONK5qbtNRDeiTfII0bT3puGIT6VF5e0QwmxNZfEZ5Zety5f3m9P/p4xqwfiLlPg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.5.27': - resolution: {integrity: sha512-FGZwJwry8qZNHxD/nfwrCwCHHrqx6epqItkkxbYcBQEQ2uw5S43bY83fQB5zbAsZE7TELMjoYkbhsQ4oKM7wwg==} + '@next/swc-darwin-x64@15.5.28': + resolution: {integrity: sha512-RgCJ7buBivTH+6sddqaVm+/v8kwMHlFkobvdt3DfETutws5HrxnToMIWSN9X3MZ3aAi7BbKrGZZWUDQLEycXqw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.5.27': - resolution: {integrity: sha512-GQulAaJYNF1RYuatlsw9/uzbKz4if2V7Ic9nVO+P0ljYPm4yBRTFIl6g5YkKTw40lMLbpy9edLoH44g3jSHMYA==} + '@next/swc-linux-arm64-gnu@15.5.28': + resolution: {integrity: sha512-OHXQe13olE59aKe++zHavGcJPh5SLHXi6Anret3BBeq1+qhSuOJDNAli/EDDfxs6UTqA4Qddo4uyYry2slyjiQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@15.5.27': - resolution: {integrity: sha512-URFFsUmu8Pi0/1qdnUI7tx7keZw1Mt5zHfrIX1RfDG9Nih8DYxK54WZsDJUX2ZTlii1jgotL7qcbwKnSAES6xQ==} + '@next/swc-linux-arm64-musl@15.5.28': + resolution: {integrity: sha512-nTB7bB6vfn4ezCJNYP43ukt2o8L3+WxKF/sTcTxepdXsJh3SM9RhlU7wrZYbH7L4HO5M87dqDTwm8587vAkCiw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@15.5.27': - resolution: {integrity: sha512-aJp74z7uvU5jQhyH7lyk+4qSRcTdKbKT9NQE3jiO9kWYkoHZjKi0zzHDbCoZ+v2sJDEiJ/z58R0UlWMGWNF3HA==} + '@next/swc-linux-x64-gnu@15.5.28': + resolution: {integrity: sha512-WXCfVI32rW3xCusoUX1RpZKtyChWU2Zwe27JUDmM3KrXwcDNc9vV4zEq0GKFAURnuQ/Ouo/XqPvB0yh445XZCg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@15.5.27': - resolution: {integrity: sha512-ILl2bQ8MbpvyMpY8AXljb3BXs0MJSC8HXZ6QV5AWpVS7ZELqNPtrHvhUk+SNarvjvICXX8bCS287TCi+gUFVYg==} + '@next/swc-linux-x64-musl@15.5.28': + resolution: {integrity: sha512-Miq+NDb/HWsXuxseYxMM6fuEOQXgTziarkKIRpVKCn8W69cEfhQaQr+90pO+u4q2CbJR2wzXmUuCFyZaJs/Nng==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@15.5.27': - resolution: {integrity: sha512-1MB9Rh5y8TOPN2x7naNTJNkCZQav9c70AI7EBYXxsEewwOffC4DjjwBwKf20NBvcA+u+5jDv7nVmHxunlP2d7Q==} + '@next/swc-win32-arm64-msvc@15.5.28': + resolution: {integrity: sha512-5LR79lemgDlgxvG7MQJ+zHCkVcrUoQuO7CiIvq3I58vNW4goSOE2b+19Z6FujRWMouvni4gvudYFuFuNxolGWA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.5.27': - resolution: {integrity: sha512-XtYgpGOswgnyBWXzOEG6q9RhvnSw0CEZ0MbWqCsl5PISgt9EzvJvNW5mU5n1PsphX/lnamzxjgQXH2GUJL8nqg==} + '@next/swc-win32-x64-msvc@15.5.28': + resolution: {integrity: sha512-GNipvXUYROSTf4PXEyH95FrWImIB5zdaK6uEOg0RP1/X2drnvoMwiBkJ0VWdzX7w3iiBm3y5g2TxkKDGVTOQcg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -7606,8 +7760,8 @@ packages: typescript: optional: true - '@remix-run/route-pattern@0.24.1': - resolution: {integrity: sha512-HRee7tz2Ct7QdTQGDErHcVTAlXa5BBV6GOLEa/INw21ceTWTWlaU13FJupYVX0k1mh608rod880+SA5NlTPPfw==} + '@remix-run/route-pattern@1.0.0': + resolution: {integrity: sha512-EsI8J5AcDpnV76lGcpwPCqCN1Vj7X2eNPqgUr+RjInTsm4iTnrGzn0iIUxpUjQ5/Ba1cAd8CMMbsA2bjEjZiyQ==} '@remix-run/router@1.23.3': resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==} @@ -8024,8 +8178,8 @@ packages: resolution: {integrity: sha512-MLxTpTU3E8QACQ/5c0sENMR2gRiMXpGaKeD5IHY+3wyU2fUSJVB0QPU/l1WhoyZbX8N9ospBgf5UEG7taVF9rg==} engines: {node: ^14.15.0 || >=16.10.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} - '@sentry/conventions@0.27.0': - resolution: {integrity: sha512-Mrqd/o7n/z7qWSYfQQA7x4fFTA0IqOG6JNsbBpnC97khPwZk7n6DPtOO7YgaYplSl8R8QLRbBz9Xv2lVhToSAg==} + '@sentry/conventions@0.29.0': + resolution: {integrity: sha512-kYq9/HJ/l6MYP/srIc20dy1W4mWyJfCF0OWFF+RhBGZwyr/wCmtabRFKyw7kqM+DTwZXQOjSTSZUcJhDowcInQ==} engines: {node: '>=14'} '@sentry/node-cpu-profiler@2.4.4': @@ -8036,17 +8190,17 @@ packages: resolution: {integrity: sha512-tF8eI4Z5YP6l7piLcy4BeGv6J96Y2ttssAAVFPgdzxzsT9i+S2olqGWgA9qav6+Gzs6E5pC9PAkObp2eDftabQ==} engines: {node: '>=18'} - '@sentry/rrdom@2.44.1': - resolution: {integrity: sha512-1w4FmGe1ddSOl7Qsu30f+SI2YdQSyEJ5Oo2zR8ymzCRJ0gaC8ZpkusGQe6tSLX3IwKL9DNLrEM4zO031+9BLtA==} + '@sentry/rrdom@2.45.0': + resolution: {integrity: sha512-JIDMWjghWbV6xlqKHiUFAInOLMYClrE1Sm5OoACQk1Bfv0+Am/Bgnb7Hj/uA2Quh8uKybn0kh9PqQ/AV3r3rzg==} - '@sentry/rrweb-snapshot@2.44.1': - resolution: {integrity: sha512-pMrOrncMfbN2gLe8IIGK6ReMThbNeCcnAxVff1EolxGHW6ry8qhVgFYhURZpDZuWLjiDOVAEcCFz/j3XTzMBGQ==} + '@sentry/rrweb-snapshot@2.45.0': + resolution: {integrity: sha512-me5T/2e58Lic5ztB9HSrko2m8gnCgDgy2Qq5iW0JbMX1FiztuwxqKku3/ZxhK10iVIgZGDqzT/d7EoJDPrC1qw==} - '@sentry/rrweb-types@2.44.1': - resolution: {integrity: sha512-qsDPN5pfR1lswCrEpGKoQp5MNC6bpta/Fi/WpS41vpV6hCI7IWB9GYzD4PDgfERslGihY3S35v+wnanktsFgaw==} + '@sentry/rrweb-types@2.45.0': + resolution: {integrity: sha512-IeCGojaSHZUgstG3nrTE5KJQkd4OvseYqsvCn+Nt18r8IkluaIiJhYstISPU8OaizZM1V5GM6DFV93Xweyqa4A==} - '@sentry/rrweb@2.44.1': - resolution: {integrity: sha512-DsxmVOlrsvSUgdbYELTtgP9zSan96NuZBM+kx85itij+/0ADBe6i2Mx2MLKQCMExenhnoZhTzf0ksZY0fkEymg==} + '@sentry/rrweb@2.45.0': + resolution: {integrity: sha512-R5ukWK6E3dgt7eE47d2m5MHPsxGtLxn3DK/AD1K6edgizWXMSP8+aatmM9cW1ZTmJhSN5mXU5YRO7QvkvH8mEA==} '@shikijs/core@1.29.2': resolution: {integrity: sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ==} @@ -9473,11 +9627,6 @@ packages: resolution: {integrity: sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==} engines: {node: '>=0.4.0'} - acorn@8.11.3: - resolution: {integrity: sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==} - engines: {node: '>=0.4.0'} - hasBin: true - acorn@8.18.0: resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} @@ -9978,8 +10127,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.11.13: - resolution: {integrity: sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==} + baseline-browser-mapping@2.11.29: + resolution: {integrity: sha512-YeesxhUmR4h5y5V8KF8iBCqJb8YFrBJ++XBTeGcWIaQOYXGN19JM0FoiOjAhwuDoln8owMgXcWBmgHtYSkky2w==} engines: {node: '>=6.0.0'} hasBin: true @@ -10153,8 +10302,8 @@ packages: peerDependencies: browserslist: '*' - browserslist@4.28.8: - resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + browserslist@4.29.0: + resolution: {integrity: sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -10307,8 +10456,8 @@ packages: caniuse-api@3.0.0: resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} - caniuse-lite@1.0.30001809: - resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} + caniuse-lite@1.0.30001816: + resolution: {integrity: sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==} capture-exit@2.0.0: resolution: {integrity: sha512-PiT/hQmTonHhl/HFGN+Lx3JJUznrVYJ3+AQsnthneZbvW7x+f08Tk7yLJTLEOUvBTbduLeeBkxEaYXUOUrRq6g==} @@ -10533,10 +10682,6 @@ packages: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} - commander@12.0.0: - resolution: {integrity: sha512-MwVNWlYjDTtOjX5PiD7o5pK0UrFU/OYgcJfjjK4RaHZETNtjJqrZa9Y9ds88+A+f+d5lv+561eZ+yCKoS3gbAA==} - engines: {node: '>=18'} - commander@12.1.0: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} @@ -11290,8 +11435,8 @@ packages: engines: {node: '>=0.12.18'} hasBin: true - electron-to-chromium@1.5.404: - resolution: {integrity: sha512-3WJtd7/lVq2Jnuz6wed1l9+1ZD2u2Tet1/1NBc4Iedkmgbu+I7YuAqdAQ8T+VZtnwysMsAf3IqSq9D1gyZjA2g==} + electron-to-chromium@1.5.459: + resolution: {integrity: sha512-4SC9HHSFjO/liAhifpdOL5hG9b4BP7/5G3y3UPZVO8trlAXr1CaUEEJL0gWK3kOcYcZrOA5rkwG45Gra/lTIgg==} elysia@1.4.29: resolution: {integrity: sha512-GwMRGGwSdjfPt+w3LA0fqTuYJtS8uVRJicvoar98/HrO5qdFKDc9CwjIb6Kja+v39lkY+58hr2JvdR9jQzlUuA==} @@ -11467,9 +11612,9 @@ packages: resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} engines: {node: '>= 0.4'} - es-check@7.2.1: - resolution: {integrity: sha512-4sxU2OZ1aYYRRX2ajL3hDDBaY96Yr/OcH6MTRerIuOSyil6SQYQQ0b48uqVfYGRCiI0NgJbtY6Sbmf75oPaTeQ==} - engines: {node: '>= 4'} + es-check@9.8.1: + resolution: {integrity: sha512-F6bumjRmN+OWpEUh3ewhE/S1ey6cHKGMNXPn0iE2tBnict3O855Uij6UYbuHnLS1E3eILvg61KdH6kHbGi9www==} + engines: {node: '>=18.0.0'} hasBin: true es-define-property@1.0.1: @@ -12134,8 +12279,8 @@ packages: resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} hasBin: true - fastify@5.12.5: - resolution: {integrity: sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==} + fastify@5.12.7: + resolution: {integrity: sha512-v4pEPsTRPuqlDIYiQFB+BHjVN4SdHX9u0ABk0ZDUKbbdY1vsNryUd1j4Z+jQEZajvYIVk047Kimva9/AJ79GYw==} fastq@1.20.3: resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} @@ -12738,10 +12883,14 @@ packages: resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} engines: {node: '>=0.10.0'} - hono@4.13.7: - resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==} + hono@4.13.11: + resolution: {integrity: sha512-/SMX/RQNJn7oNmFwH6DtwDqcrzZU2otm1FD6OJe2cWF6cfy/F8hq0XVBv7xW3FTJshRQwuBnXHDq2kNsdZnshg==} engines: {node: '>=16.9.0'} + hono@5.0.0-rc.1: + resolution: {integrity: sha512-vTcfU9yAggXh/+ZSi8zpYbzYDubCgFnSt8d5afCAlgkd38dyYb3CVtOh8YWhS65NkEqBseCsTj/P8mceJ+7KNA==} + engines: {node: '>=22.12.0'} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -14655,8 +14804,8 @@ packages: resolution: {integrity: sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==} engines: {node: '>=12.22.0'} - next@15.5.27: - resolution: {integrity: sha512-F82CrlPZ8GRxBH9RIIoR3qU1t5RT3dv1K8moe8SjlDEquiFipvEI13R1Om90TaUOEwShkWCMlg8+gvTNzfMhvg==} + next@15.5.28: + resolution: {integrity: sha512-JjhLBBY1PNnuwPtcZ7t8yxVeJVYvD2Jyp633J+gRCNbzSt7N0E+xqCf6uQiff3+3qmUkwcF/ksfSsK1xsZ76Tw==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: @@ -14818,8 +14967,8 @@ packages: node-mock-http@1.0.4: resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} - node-releases@2.0.53: - resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} + node-releases@2.0.59: + resolution: {integrity: sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==} engines: {node: '>=18'} node-schedule@2.1.1: @@ -18424,8 +18573,8 @@ packages: unzip-stream@0.3.4: resolution: {integrity: sha512-PyofABPVv+d7fL7GOpusx7eRT9YETY2X04PhwbSipdj6bMxVCFJrr+nm0Mxqbf9hUiTin/UsnuFWBXlDZFy0Cw==} - update-browserslist-db@1.3.1: - resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} + update-browserslist-db@1.3.4: + resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -19133,10 +19282,6 @@ packages: resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} engines: {node: '>= 12.0.0'} - winston@3.13.0: - resolution: {integrity: sha512-rwidmA1w3SE4j0E5MuIufFhyJPBDG7Nu71RkZor1p2+qHvJSZ9GYDA81AyleQcZbh/+V6HjeBdfnTZJm9rSeQQ==} - engines: {node: '>= 12.0.0'} - winston@3.19.0: resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==} engines: {node: '>= 12.0.0'} @@ -19361,8 +19506,8 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.6.5: - resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} zone.js@0.12.0: resolution: {integrity: sha512-XtC+I5dXU14HrzidAKBNMqneIVUykLEAA1x+v4KVrd6AUPWlwYORF8KgsVqvgdHiKZ4BkxxjvYi/ksEixTPR0Q==} @@ -19454,26 +19599,26 @@ snapshots: '@adobe/css-tools@4.4.3': {} - '@ai-sdk/gateway@3.0.142(zod@4.6.5)': + '@ai-sdk/gateway@3.0.142(zod@4.6.7)': dependencies: '@ai-sdk/provider': 3.0.13 - '@ai-sdk/provider-utils': 4.0.35(zod@4.6.5) + '@ai-sdk/provider-utils': 4.0.35(zod@4.6.7) '@vercel/oidc': 3.2.0 - zod: 4.6.5 + zod: 4.6.7 - '@ai-sdk/provider-utils@2.2.8(zod@3.25.76)': + '@ai-sdk/provider-utils@2.2.8(zod@4.6.7)': dependencies: '@ai-sdk/provider': 1.1.3 nanoid: 3.3.20 secure-json-parse: 2.7.0 - zod: 3.25.76 + zod: 4.6.7 - '@ai-sdk/provider-utils@4.0.35(zod@4.6.5)': + '@ai-sdk/provider-utils@4.0.35(zod@4.6.7)': dependencies: '@ai-sdk/provider': 3.0.13 '@standard-schema/spec': 1.1.0 eventsource-parser: 3.1.1 - zod: 4.6.5 + zod: 4.6.7 '@ai-sdk/provider@1.1.3': dependencies: @@ -19483,22 +19628,22 @@ snapshots: dependencies: json-schema: 0.4.0 - '@ai-sdk/react@1.2.12(react@18.3.1)(zod@3.25.76)': + '@ai-sdk/react@1.2.12(react@18.3.1)(zod@4.6.7)': dependencies: - '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) - '@ai-sdk/ui-utils': 1.2.11(zod@3.25.76) + '@ai-sdk/provider-utils': 2.2.8(zod@4.6.7) + '@ai-sdk/ui-utils': 1.2.11(zod@4.6.7) react: 18.3.1 swr: 2.2.5(react@18.3.1) throttleit: 2.1.0 optionalDependencies: - zod: 3.25.76 + zod: 4.6.7 - '@ai-sdk/ui-utils@1.2.11(zod@3.25.76)': + '@ai-sdk/ui-utils@1.2.11(zod@4.6.7)': dependencies: '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) - zod: 3.25.76 - zod-to-json-schema: 3.25.2(zod@3.25.76) + '@ai-sdk/provider-utils': 2.2.8(zod@4.6.7) + zod: 4.6.7 + zod-to-json-schema: 3.25.2(zod@4.6.7) '@ampproject/remapping@2.2.0': dependencies: @@ -19538,7 +19683,7 @@ snapshots: ansi-colors: 4.1.3 babel-loader: 8.2.5(@babel/core@7.18.10(supports-color@10.0.0))(webpack@5.76.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.15.5)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.4.31)(uglify-js@3.13.3)) babel-plugin-istanbul: 6.1.1(supports-color@10.0.0) - browserslist: 4.28.8 + browserslist: 4.29.0 cacache: 16.1.2 copy-webpack-plugin: 11.0.0(webpack@5.76.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.15.5)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.4.31)(uglify-js@3.13.3)) critters: 0.0.16 @@ -19722,32 +19867,30 @@ snapshots: rxjs: 7.8.2 tslib: 2.8.1 - '@anthropic-ai/sdk@0.133.0(zod@3.25.76)': + '@anthropic-ai/sdk@0.133.0(zod@4.6.7)': dependencies: json-schema-to-ts: 3.1.1 standardwebhooks: 1.1.1 optionalDependencies: - zod: 3.25.76 + zod: 4.6.7 - '@anthropic-ai/sdk@0.133.0(zod@4.6.5)': + '@anthropic-ai/sdk@0.65.0(zod@4.6.7)': dependencies: json-schema-to-ts: 3.1.1 - standardwebhooks: 1.1.1 optionalDependencies: - zod: 4.6.5 + zod: 4.6.7 - '@anthropic-ai/sdk@0.65.0(zod@3.25.76)': - dependencies: - json-schema-to-ts: 3.1.1 - optionalDependencies: - zod: 3.25.76 - - '@apm-js-collab/code-transformer-bundler-plugins@0.7.4': + '@apm-js-collab/code-transformer-bundler-plugins@0.7.4(esbuild@0.28.1)(rollup@4.62.3)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.104.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))': dependencies: '@apm-js-collab/code-transformer': 0.18.1 es-module-lexer: 2.1.0 magic-string: 0.30.21 module-details-from-path: 1.0.4 + optionalDependencies: + esbuild: 0.28.1 + rollup: 4.62.3 + vite: 8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0) + webpack: 5.104.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3) '@apm-js-collab/code-transformer@0.18.1': dependencies: @@ -20809,7 +20952,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.8 + browserslist: 4.29.0 lru-cache: 5.1.1 semver: 6.3.1 @@ -22706,8 +22849,8 @@ snapshots: '@embroider/macros': 1.20.6(@babel/core@7.29.7(supports-color@10.0.0))(@glint/template@1.8.0)(supports-color@10.0.0) '@embroider/reverse-exports': 0.2.0 assert-never: 1.4.0 - browserslist: 4.28.8 - browserslist-to-esbuild: 2.1.1(browserslist@4.28.8) + browserslist: 4.29.0 + browserslist-to-esbuild: 2.1.1(browserslist@4.29.0) chalk: 5.6.2 content-tag: 4.2.0 debug: 4.4.3(supports-color@10.0.0) @@ -23485,9 +23628,9 @@ snapshots: '@hapi/bourne': 3.0.0 '@hapi/hoek': 11.0.4 - '@hono/node-server@2.0.10(hono@4.13.7)': + '@hono/node-server@2.0.10(hono@4.13.11)': dependencies: - hono: 4.13.7 + hono: 4.13.11 '@humanwhocodes/config-array@0.11.14(supports-color@10.0.0)': dependencies: @@ -23875,22 +24018,22 @@ snapshots: '@kwsites/promise-deferred@1.1.1': {} - '@langchain/anthropic@0.3.31(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@3.25.76)))(zod@3.25.76)': + '@langchain/anthropic@0.3.31(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7)))(zod@4.6.7)': dependencies: - '@anthropic-ai/sdk': 0.65.0(zod@3.25.76) - '@langchain/core': 0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@3.25.76)) + '@anthropic-ai/sdk': 0.65.0(zod@4.6.7) + '@langchain/core': 0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7)) fast-xml-parser: 4.5.6 transitivePeerDependencies: - zod - '@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@3.25.76))': + '@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))': dependencies: '@cfworker/json-schema': 4.1.1 ansi-styles: 5.2.0 camelcase: 6.3.0 decamelize: 1.2.0 js-tiktoken: 1.0.21 - langsmith: 0.3.74(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@3.25.76)) + langsmith: 0.3.74(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7)) mustache: 4.2.0 p-queue: 6.6.2 p-retry: 4.6.2 @@ -23903,15 +24046,15 @@ snapshots: - '@opentelemetry/sdk-trace-base' - openai - '@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3)': + '@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3)': dependencies: '@cfworker/json-schema': 4.1.1 '@standard-schema/spec': 1.1.0 js-tiktoken: 1.0.21 - langsmith: 0.10.10(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3) + langsmith: 0.10.10(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3) mustache: 4.2.0 p-queue: 6.6.2 - zod: 3.25.76 + zod: 4.6.7 transitivePeerDependencies: - '@opentelemetry/api' - '@opentelemetry/exporter-trace-otlp-proto' @@ -23919,29 +24062,29 @@ snapshots: - openai - ws - '@langchain/langgraph-checkpoint@0.0.18(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@3.25.76)))': + '@langchain/langgraph-checkpoint@0.0.18(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7)))': dependencies: - '@langchain/core': 0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@3.25.76)) + '@langchain/core': 0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7)) uuid: 10.0.0 - '@langchain/langgraph-checkpoint@1.1.6(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))': + '@langchain/langgraph-checkpoint@1.1.6(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3))': dependencies: - '@langchain/core': 1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3) + '@langchain/core': 1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3) - '@langchain/langgraph-sdk@0.0.112(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@3.25.76)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@langchain/langgraph-sdk@0.0.112(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@types/json-schema': 7.0.15 p-queue: 6.6.2 p-retry: 4.6.2 uuid: 9.0.1 optionalDependencies: - '@langchain/core': 0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@3.25.76)) + '@langchain/core': 0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7)) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@langchain/langgraph-sdk@1.12.3(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@langchain/langgraph-sdk@1.12.3(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@langchain/core': 1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3) + '@langchain/core': 1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3) '@langchain/protocol': 0.0.19 '@types/json-schema': 7.0.15 p-queue: 9.3.3 @@ -23950,58 +24093,46 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@langchain/langgraph@0.2.74(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@3.25.76)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod-to-json-schema@3.25.2(zod@3.25.76))': + '@langchain/langgraph@0.2.74(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod-to-json-schema@3.25.2(zod@4.6.7))': dependencies: - '@langchain/core': 0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@3.25.76)) - '@langchain/langgraph-checkpoint': 0.0.18(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@3.25.76))) - '@langchain/langgraph-sdk': 0.0.112(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@3.25.76)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@langchain/core': 0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7)) + '@langchain/langgraph-checkpoint': 0.0.18(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))) + '@langchain/langgraph-sdk': 0.0.112(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) uuid: 10.0.0 zod: 3.25.76 optionalDependencies: - zod-to-json-schema: 3.25.2(zod@3.25.76) + zod-to-json-schema: 3.25.2(zod@4.6.7) transitivePeerDependencies: - react - react-dom - '@langchain/langgraph@1.4.21(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod@3.25.76)': + '@langchain/langgraph@1.4.21(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod@4.6.7)': dependencies: - '@langchain/core': 1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3) - '@langchain/langgraph-checkpoint': 1.1.6(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3)) - '@langchain/langgraph-sdk': 1.12.3(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@langchain/core': 1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3) + '@langchain/langgraph-checkpoint': 1.1.6(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3)) + '@langchain/langgraph-sdk': 1.12.3(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@langchain/protocol': 0.0.19 '@standard-schema/spec': 1.1.0 - zod: 3.25.76 + zod: 4.6.7 transitivePeerDependencies: - react - react-dom - '@langchain/langgraph@1.4.21(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod@4.6.5)': + '@langchain/openai@0.5.18(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7)))(ws@8.21.3)': dependencies: - '@langchain/core': 1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3) - '@langchain/langgraph-checkpoint': 1.1.6(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3)) - '@langchain/langgraph-sdk': 1.12.3(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@langchain/protocol': 0.0.19 - '@standard-schema/spec': 1.1.0 - zod: 4.6.5 - transitivePeerDependencies: - - react - - react-dom - - '@langchain/openai@0.5.18(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@3.25.76)))(ws@8.21.3)': - dependencies: - '@langchain/core': 0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@3.25.76)) + '@langchain/core': 0.3.80(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7)) js-tiktoken: 1.0.21 openai: 5.18.1(ws@8.21.3)(zod@3.25.76) zod: 3.25.76 transitivePeerDependencies: - ws - '@langchain/openai@1.6.2(@aws-sdk/credential-provider-node@3.972.84)(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))(@smithy/signature-v4@5.7.4)(undici@7.24.8)(ws@8.21.3)': + '@langchain/openai@1.6.2(@aws-sdk/credential-provider-node@3.972.84)(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3))(@smithy/signature-v4@5.7.4)(undici@7.24.8)(ws@8.21.3)': dependencies: - '@langchain/core': 1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3) + '@langchain/core': 1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3) js-tiktoken: 1.0.21 - openai: 7.31.0(@aws-sdk/credential-provider-node@3.972.84)(@smithy/signature-v4@5.7.4)(undici@7.24.8)(ws@8.21.3)(zod@3.25.76) - zod: 3.25.76 + openai: 7.31.0(@aws-sdk/credential-provider-node@3.972.84)(@smithy/signature-v4@5.7.4)(undici@7.24.8)(ws@8.21.3)(zod@4.6.7) + zod: 4.6.7 transitivePeerDependencies: - '@aws-sdk/credential-provider-node' - '@smithy/hash-node' @@ -24011,12 +24142,12 @@ snapshots: '@langchain/protocol@0.0.19': {} - '@langchain/typesafe@0.0.2(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))(langchain@1.5.16(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(ws@8.21.3))': + '@langchain/typesafe@0.0.2(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3))(langchain@1.5.16(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3))(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(ws@8.21.3))': dependencies: - '@langchain/core': 1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3) - zod: 4.6.5 + '@langchain/core': 1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3) + zod: 4.6.7 optionalDependencies: - langchain: 1.5.16(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(ws@8.21.3) + langchain: 1.5.16(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3))(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(ws@8.21.3) '@leichtgewicht/ip-codec@2.0.4': {} @@ -24052,8 +24183,8 @@ snapshots: dependencies: '@opentelemetry/semantic-conventions': 1.43.0 ws: 8.21.3 - zod: 3.25.76 - zod-to-json-schema: 3.25.2(zod@3.25.76) + zod: 4.6.7 + zod-to-json-schema: 3.25.2(zod@4.6.7) optionalDependencies: '@opentelemetry/api': 1.9.0 transitivePeerDependencies: @@ -24070,16 +24201,16 @@ snapshots: eventsource-parser: 3.1.1 jose: 6.2.10 pkce-challenge: 5.0.1 - zod: 4.6.5 + zod: 4.6.7 '@modelcontextprotocol/core@2.0.0': dependencies: - zod: 4.6.5 + zod: 4.6.7 '@modelcontextprotocol/server@2.0.0': dependencies: '@modelcontextprotocol/core': 2.0.0 - zod: 4.6.5 + zod: 4.6.7 '@mongodb-js/saslprep@1.4.6': dependencies: @@ -24162,30 +24293,30 @@ snapshots: transitivePeerDependencies: - supports-color - '@next/env@15.5.27': {} + '@next/env@15.5.28': {} - '@next/swc-darwin-arm64@15.5.27': + '@next/swc-darwin-arm64@15.5.28': optional: true - '@next/swc-darwin-x64@15.5.27': + '@next/swc-darwin-x64@15.5.28': optional: true - '@next/swc-linux-arm64-gnu@15.5.27': + '@next/swc-linux-arm64-gnu@15.5.28': optional: true - '@next/swc-linux-arm64-musl@15.5.27': + '@next/swc-linux-arm64-musl@15.5.28': optional: true - '@next/swc-linux-x64-gnu@15.5.27': + '@next/swc-linux-x64-gnu@15.5.28': optional: true - '@next/swc-linux-x64-musl@15.5.27': + '@next/swc-linux-x64-musl@15.5.28': optional: true - '@next/swc-win32-arm64-msvc@15.5.27': + '@next/swc-win32-arm64-msvc@15.5.28': optional: true - '@next/swc-win32-x64-msvc@15.5.27': + '@next/swc-win32-x64-msvc@15.5.28': optional: true '@ngtools/webpack@14.2.13(@angular/compiler-cli@14.3.0(@angular/compiler@14.3.0(@angular/core@14.3.0(rxjs@7.8.2)(zone.js@0.12.0)))(supports-color@10.0.0)(typescript@4.6.4))(typescript@4.6.4)(webpack@5.76.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.15.5)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.4.31)(uglify-js@3.13.3))': @@ -24217,7 +24348,7 @@ snapshots: '@npmcli/fs@2.1.2': dependencies: '@gar/promisify': 1.1.3 - semver: 7.5.3 + semver: 7.8.5 '@npmcli/git@3.0.2': dependencies: @@ -24228,7 +24359,7 @@ snapshots: proc-log: 2.0.1 promise-inflight: 1.0.1 promise-retry: 2.0.1 - semver: 7.5.3 + semver: 7.8.5 which: 2.0.2 transitivePeerDependencies: - bluebird @@ -25691,7 +25822,7 @@ snapshots: optionalDependencies: typescript: 5.8.3 - '@remix-run/route-pattern@0.24.1': {} + '@remix-run/route-pattern@1.0.0': {} '@remix-run/router@1.23.3': {} @@ -26004,7 +26135,7 @@ snapshots: transitivePeerDependencies: - chokidar - '@sentry/conventions@0.27.0': {} + '@sentry/conventions@0.29.0': {} '@sentry/node-cpu-profiler@2.4.4': dependencies: @@ -26016,22 +26147,22 @@ snapshots: detect-libc: 2.1.2 node-abi: 3.89.0 - '@sentry/rrdom@2.44.1': + '@sentry/rrdom@2.45.0': dependencies: - '@sentry/rrweb-snapshot': 2.44.1 + '@sentry/rrweb-snapshot': 2.45.0 - '@sentry/rrweb-snapshot@2.44.1': {} + '@sentry/rrweb-snapshot@2.45.0': {} - '@sentry/rrweb-types@2.44.1': + '@sentry/rrweb-types@2.45.0': dependencies: - '@sentry/rrweb-snapshot': 2.44.1 + '@sentry/rrweb-snapshot': 2.45.0 '@types/css-font-loading-module': 0.0.7 - '@sentry/rrweb@2.44.1': + '@sentry/rrweb@2.45.0': dependencies: - '@sentry/rrdom': 2.44.1 - '@sentry/rrweb-snapshot': 2.44.1 - '@sentry/rrweb-types': 2.44.1 + '@sentry/rrdom': 2.45.0 + '@sentry/rrweb-snapshot': 2.45.0 + '@sentry/rrweb-types': 2.45.0 '@types/css-font-loading-module': 0.0.7 '@xstate/fsm': 1.6.5 base64-arraybuffer: 1.0.2 @@ -26883,7 +27014,7 @@ snapshots: '@types/body-parser@1.19.2': dependencies: '@types/connect': 3.4.38 - '@types/node': 14.18.63 + '@types/node': 20.19.43 '@types/bonjour@3.5.13': dependencies: @@ -26906,7 +27037,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 14.18.63 + '@types/node': 20.19.43 '@types/cookie@0.6.0': {} @@ -26938,7 +27069,7 @@ snapshots: '@types/express-serve-static-core@4.17.43': dependencies: - '@types/node': 14.18.63 + '@types/node': 20.19.43 '@types/qs': 6.9.6 '@types/range-parser': 1.2.3 '@types/send': 0.17.4 @@ -26955,7 +27086,7 @@ snapshots: '@types/body-parser': 1.19.2 '@types/express-serve-static-core': 4.17.43 '@types/qs': 6.9.6 - '@types/serve-static': 1.15.5 + '@types/serve-static': 2.2.0 '@types/express@5.0.6': dependencies: @@ -27111,7 +27242,7 @@ snapshots: '@types/send@0.17.4': dependencies: '@types/mime': 1.3.2 - '@types/node': 14.18.63 + '@types/node': 20.19.43 '@types/serve-index@1.9.4': dependencies: @@ -27961,8 +28092,6 @@ snapshots: dependencies: acorn: 8.18.0 - acorn@8.11.3: {} - acorn@8.18.0: {} adjust-sourcemap-loader@4.0.0: @@ -28004,25 +28133,25 @@ snapshots: clean-stack: 2.2.0 indent-string: 4.0.0 - ai@4.3.16(react@18.3.1)(zod@3.25.76): + ai@4.3.16(react@18.3.1)(zod@4.6.7): dependencies: '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) - '@ai-sdk/react': 1.2.12(react@18.3.1)(zod@3.25.76) - '@ai-sdk/ui-utils': 1.2.11(zod@3.25.76) + '@ai-sdk/provider-utils': 2.2.8(zod@4.6.7) + '@ai-sdk/react': 1.2.12(react@18.3.1)(zod@4.6.7) + '@ai-sdk/ui-utils': 1.2.11(zod@4.6.7) '@opentelemetry/api': 1.9.0 jsondiffpatch: 0.6.0 - zod: 3.25.76 + zod: 4.6.7 optionalDependencies: react: 18.3.1 - ai@6.0.218(zod@4.6.5): + ai@6.0.218(zod@4.6.7): dependencies: - '@ai-sdk/gateway': 3.0.142(zod@4.6.5) + '@ai-sdk/gateway': 3.0.142(zod@4.6.7) '@ai-sdk/provider': 3.0.13 - '@ai-sdk/provider-utils': 4.0.35(zod@4.6.5) + '@ai-sdk/provider-utils': 4.0.35(zod@4.6.7) '@opentelemetry/api': 1.10.0 - zod: 4.6.5 + zod: 4.6.7 ajv-formats@2.1.1: dependencies: @@ -28400,8 +28529,8 @@ snapshots: autoprefixer@10.5.4(postcss@8.4.31): dependencies: - browserslist: 4.28.8 - caniuse-lite: 1.0.30001809 + browserslist: 4.29.0 + caniuse-lite: 1.0.30001816 fraction.js: 5.3.4 picocolors: 1.1.1 postcss: 8.4.31 @@ -28409,8 +28538,8 @@ snapshots: autoprefixer@10.5.4(postcss@8.5.31): dependencies: - browserslist: 4.28.8 - caniuse-lite: 1.0.30001809 + browserslist: 4.29.0 + caniuse-lite: 1.0.30001816 fraction.js: 5.3.4 picocolors: 1.1.1 postcss: 8.5.31 @@ -28623,7 +28752,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.11.13: {} + baseline-browser-mapping@2.11.29: {} basic-auth-parser@0.0.2-1: {} @@ -28900,18 +29029,18 @@ snapshots: transitivePeerDependencies: - supports-color - browserslist-to-esbuild@2.1.1(browserslist@4.28.8): + browserslist-to-esbuild@2.1.1(browserslist@4.29.0): dependencies: - browserslist: 4.28.8 + browserslist: 4.29.0 meow: 13.2.0 - browserslist@4.28.8: + browserslist@4.29.0: dependencies: - baseline-browser-mapping: 2.11.13 - caniuse-lite: 1.0.30001809 - electron-to-chromium: 1.5.404 - node-releases: 2.0.53 - update-browserslist-db: 1.3.1(browserslist@4.28.8) + baseline-browser-mapping: 2.11.29 + caniuse-lite: 1.0.30001816 + electron-to-chromium: 1.5.459 + node-releases: 2.0.59 + update-browserslist-db: 1.3.4(browserslist@4.29.0) bser@2.1.1: dependencies: @@ -28951,7 +29080,7 @@ snapshots: builtins@5.0.1: dependencies: - semver: 7.5.3 + semver: 7.8.5 bun-types@1.2.9: dependencies: @@ -29101,12 +29230,12 @@ snapshots: caniuse-api@3.0.0: dependencies: - browserslist: 4.28.8 - caniuse-lite: 1.0.30001809 + browserslist: 4.29.0 + caniuse-lite: 1.0.30001816 lodash.memoize: 4.1.2 lodash.uniq: 4.5.0 - caniuse-lite@1.0.30001809: {} + caniuse-lite@1.0.30001816: {} capture-exit@2.0.0: dependencies: @@ -29310,8 +29439,6 @@ snapshots: commander@11.1.0: {} - commander@12.0.0: {} - commander@12.1.0: {} commander@14.0.3: {} @@ -29458,7 +29585,7 @@ snapshots: core-js-compat@3.50.0: dependencies: - browserslist: 4.28.8 + browserslist: 4.29.0 core-js-pure@3.43.0: {} @@ -29560,7 +29687,7 @@ snapshots: postcss-modules-scope: 3.0.0(postcss@8.4.31) postcss-modules-values: 4.0.0(postcss@8.4.31) postcss-value-parser: 4.2.0 - semver: 7.5.3 + semver: 7.8.5 webpack: 5.76.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.15.5)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.4.31)(uglify-js@3.13.3) css-prefers-color-scheme@6.0.3(postcss@8.4.31): @@ -29614,7 +29741,7 @@ snapshots: cssnano-preset-default@7.0.17(postcss@8.5.31): dependencies: - browserslist: 4.28.8 + browserslist: 4.29.0 css-declaration-sorter: 7.2.0(postcss@8.5.31) cssnano-utils: 5.0.3(postcss@8.5.31) postcss: 8.5.31 @@ -30084,7 +30211,7 @@ snapshots: ejs@5.0.1: {} - electron-to-chromium@1.5.404: {} + electron-to-chromium@1.5.459: {} elysia@1.4.29(file-type@21.3.4(supports-color@10.0.0))(typescript@7.0.2): dependencies: @@ -30341,13 +30468,11 @@ snapshots: unbox-primitive: 1.1.0 which-typed-array: 1.1.19 - es-check@7.2.1: + es-check@9.8.1: dependencies: - acorn: 8.11.3 - commander: 12.0.0 + acorn: 8.18.0 + browserslist: 4.29.0 fast-glob: 3.3.3 - supports-color: 8.1.1 - winston: 3.13.0 es-define-property@1.0.1: {} @@ -31184,7 +31309,7 @@ snapshots: path-expression-matcher: 1.5.0 strnum: 2.2.3 - fastify@5.12.5: + fastify@5.12.7: dependencies: '@fastify/ajv-compiler': 4.0.5 '@fastify/error': 4.2.0 @@ -32048,7 +32173,9 @@ snapshots: dependencies: parse-passwd: 1.0.0 - hono@4.13.7: {} + hono@4.13.11: {} + + hono@5.0.0-rc.1: {} hookable@5.5.3: {} @@ -33102,13 +33229,13 @@ snapshots: ky@1.7.5: {} - langchain@1.5.16(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(ws@8.21.3): + langchain@1.5.16(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3))(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(ws@8.21.3): dependencies: - '@langchain/core': 1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3) - '@langchain/langgraph': 1.4.21(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod@3.25.76) - '@langchain/langgraph-checkpoint': 1.1.6(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3)) - langsmith: 0.10.10(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3) - zod: 3.25.76 + '@langchain/core': 1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3) + '@langchain/langgraph': 1.4.21(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod@4.6.7) + '@langchain/langgraph-checkpoint': 1.1.6(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3)) + langsmith: 0.10.10(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3) + zod: 4.6.7 transitivePeerDependencies: - '@opentelemetry/api' - '@opentelemetry/exporter-trace-otlp-proto' @@ -33118,15 +33245,15 @@ snapshots: - react-dom - ws - langsmith@0.10.10(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3): + langsmith@0.10.10(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3): dependencies: p-queue: 6.6.2 optionalDependencies: '@opentelemetry/api': 1.10.0 - openai: 5.18.1(ws@8.21.3)(zod@4.6.5) + openai: 5.18.1(ws@8.21.3)(zod@4.6.7) ws: 8.21.3 - langsmith@0.3.74(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@3.25.76)): + langsmith@0.3.74(@opentelemetry/api@1.9.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7)): dependencies: '@types/uuid': 10.0.0 chalk: 4.1.2 @@ -33137,7 +33264,7 @@ snapshots: uuid: 10.0.0 optionalDependencies: '@opentelemetry/api': 1.9.0 - openai: 5.18.1(ws@8.21.3)(zod@3.25.76) + openai: 5.18.1(ws@8.21.3)(zod@4.6.7) launch-editor@2.14.1: dependencies: @@ -34315,24 +34442,24 @@ snapshots: transitivePeerDependencies: - supports-color - next@15.5.27(@babel/core@7.29.7(supports-color@10.0.0))(@opentelemetry/api@1.10.0)(@playwright/test@1.64.1)(@types/node@20.19.43)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0): + next@15.5.28(@babel/core@7.29.7(supports-color@10.0.0))(@opentelemetry/api@1.10.0)(@playwright/test@1.64.1)(@types/node@20.19.43)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0): dependencies: - '@next/env': 15.5.27 + '@next/env': 15.5.28 '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001809 + caniuse-lite: 1.0.30001816 postcss: 8.4.31 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@10.0.0))(react@18.3.1) optionalDependencies: - '@next/swc-darwin-arm64': 15.5.27 - '@next/swc-darwin-x64': 15.5.27 - '@next/swc-linux-arm64-gnu': 15.5.27 - '@next/swc-linux-arm64-musl': 15.5.27 - '@next/swc-linux-x64-gnu': 15.5.27 - '@next/swc-linux-x64-musl': 15.5.27 - '@next/swc-win32-arm64-msvc': 15.5.27 - '@next/swc-win32-x64-msvc': 15.5.27 + '@next/swc-darwin-arm64': 15.5.28 + '@next/swc-darwin-x64': 15.5.28 + '@next/swc-linux-arm64-gnu': 15.5.28 + '@next/swc-linux-arm64-musl': 15.5.28 + '@next/swc-linux-x64-gnu': 15.5.28 + '@next/swc-linux-x64-musl': 15.5.28 + '@next/swc-win32-arm64-msvc': 15.5.28 + '@next/swc-win32-x64-msvc': 15.5.28 '@opentelemetry/api': 1.10.0 '@playwright/test': 1.64.1 sass: 1.72.0 @@ -34351,7 +34478,7 @@ snapshots: '@rollup/plugin-node-resolve': 13.3.0(rollup@2.80.0) ajv: 8.20.0 ansi-colors: 4.1.3 - browserslist: 4.28.8 + browserslist: 4.29.0 cacache: 16.1.3 chokidar: 3.6.0 commander: 9.5.0 @@ -34866,7 +34993,7 @@ snapshots: nopt: 6.0.0 npmlog: 6.0.2 rimraf: 3.0.2 - semver: 7.5.3 + semver: 7.8.5 tar: 6.2.1 which: 2.0.2 transitivePeerDependencies: @@ -34877,7 +35004,7 @@ snapshots: node-mock-http@1.0.4: {} - node-releases@2.0.53: {} + node-releases@2.0.59: {} node-schedule@2.1.1: dependencies: @@ -34918,7 +35045,7 @@ snapshots: dependencies: hosted-git-info: 5.1.0 is-core-module: 2.16.1 - semver: 7.5.3 + semver: 7.8.5 validate-npm-package-license: 3.0.4 normalize-path@3.0.0: {} @@ -34935,7 +35062,7 @@ snapshots: npm-install-checks@5.0.0: dependencies: - semver: 7.5.3 + semver: 7.8.5 npm-normalize-package-bin@1.0.1: {} @@ -34947,7 +35074,7 @@ snapshots: dependencies: hosted-git-info: 5.1.0 proc-log: 2.0.1 - semver: 7.5.3 + semver: 7.8.5 validate-npm-package-name: 4.0.0 npm-packlist@2.2.2: @@ -34969,7 +35096,7 @@ snapshots: npm-install-checks: 5.0.0 npm-normalize-package-bin: 1.0.1 npm-package-arg: 9.1.0 - semver: 7.5.3 + semver: 7.8.5 npm-registry-fetch@13.3.1(supports-color@10.0.0): dependencies: @@ -35421,18 +35548,18 @@ snapshots: ws: 8.21.3 zod: 3.25.76 - openai@5.18.1(ws@8.21.3)(zod@4.6.5): + openai@5.18.1(ws@8.21.3)(zod@4.6.7): optionalDependencies: ws: 8.21.3 - zod: 4.6.5 + zod: 4.6.7 - openai@7.31.0(@aws-sdk/credential-provider-node@3.972.84)(@smithy/signature-v4@5.7.4)(undici@7.24.8)(ws@8.21.3)(zod@3.25.76): + openai@7.31.0(@aws-sdk/credential-provider-node@3.972.84)(@smithy/signature-v4@5.7.4)(undici@7.24.8)(ws@8.21.3)(zod@4.6.7): optionalDependencies: '@aws-sdk/credential-provider-node': 3.972.84 '@smithy/signature-v4': 5.7.4 undici: 7.24.8 ws: 8.21.3 - zod: 3.25.76 + zod: 4.6.7 optional-require@1.1.8: dependencies: @@ -36113,14 +36240,14 @@ snapshots: postcss-colormin@7.0.10(postcss@8.5.31): dependencies: '@colordx/core': 5.5.0 - browserslist: 4.28.8 + browserslist: 4.29.0 caniuse-api: 3.0.0 postcss: 8.5.31 postcss-value-parser: 4.2.0 postcss-convert-values@7.0.12(postcss@8.5.31): dependencies: - browserslist: 4.28.8 + browserslist: 4.29.0 postcss: 8.5.31 postcss-value-parser: 4.2.0 @@ -36281,7 +36408,7 @@ snapshots: cosmiconfig: 7.1.0 klona: 2.0.6 postcss: 8.4.31 - semver: 7.5.3 + semver: 7.8.5 webpack: 5.76.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.15.5)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.4.31)(uglify-js@3.13.3) postcss-logical@5.0.4(postcss@8.4.31): @@ -36308,7 +36435,7 @@ snapshots: postcss-merge-rules@7.0.11(postcss@8.5.31): dependencies: - browserslist: 4.28.8 + browserslist: 4.29.0 caniuse-api: 3.0.0 cssnano-utils: 5.0.3(postcss@8.5.31) postcss: 8.5.31 @@ -36328,14 +36455,14 @@ snapshots: postcss-minify-params@7.0.9(postcss@8.5.31): dependencies: - browserslist: 4.28.8 + browserslist: 4.29.0 cssnano-utils: 5.0.3(postcss@8.5.31) postcss: 8.5.31 postcss-value-parser: 4.2.0 postcss-minify-selectors@7.1.2(postcss@8.5.31): dependencies: - browserslist: 4.28.8 + browserslist: 4.29.0 caniuse-api: 3.0.0 cssesc: 3.0.0 postcss: 8.5.31 @@ -36405,7 +36532,7 @@ snapshots: postcss-normalize-unicode@7.0.9(postcss@8.5.31): dependencies: - browserslist: 4.28.8 + browserslist: 4.29.0 postcss: 8.5.31 postcss-value-parser: 4.2.0 @@ -36478,7 +36605,7 @@ snapshots: '@csstools/postcss-trigonometric-functions': 1.0.2(postcss@8.4.31) '@csstools/postcss-unset-value': 1.0.2(postcss@8.4.31) autoprefixer: 10.5.4(postcss@8.4.31) - browserslist: 4.28.8 + browserslist: 4.29.0 css-blank-pseudo: 3.0.3(postcss@8.4.31) css-has-pseudo: 3.0.4(postcss@8.4.31) css-prefers-color-scheme: 6.0.3(postcss@8.4.31) @@ -36531,7 +36658,7 @@ snapshots: '@csstools/postcss-trigonometric-functions': 1.0.2(postcss@8.5.31) '@csstools/postcss-unset-value': 1.0.2(postcss@8.5.31) autoprefixer: 10.5.4(postcss@8.5.31) - browserslist: 4.28.8 + browserslist: 4.29.0 css-blank-pseudo: 3.0.3(postcss@8.5.31) css-has-pseudo: 3.0.4(postcss@8.5.31) css-prefers-color-scheme: 6.0.3(postcss@8.5.31) @@ -36579,7 +36706,7 @@ snapshots: postcss-reduce-initial@7.0.9(postcss@8.5.31): dependencies: - browserslist: 4.28.8 + browserslist: 4.29.0 caniuse-api: 3.0.0 postcss: 8.5.31 @@ -38431,7 +38558,7 @@ snapshots: stylehacks@7.0.11(postcss@8.5.31): dependencies: - browserslist: 4.28.8 + browserslist: 4.29.0 postcss: 8.5.31 postcss-selector-parser: 7.1.5 @@ -39434,9 +39561,9 @@ snapshots: binary: 0.3.0 mkdirp: 0.5.6 - update-browserslist-db@1.3.1(browserslist@4.28.8): + update-browserslist-db@1.3.4(browserslist@4.29.0): dependencies: - browserslist: 4.28.8 + browserslist: 4.29.0 escalade: 3.2.0 picocolors: 1.1.1 @@ -39555,7 +39682,7 @@ snapshots: unenv: 1.10.0 unstorage: 1.17.5(@azure/storage-blob@12.31.0(supports-color@10.0.0))(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.11.0(supports-color@10.0.0)) vite: 6.4.3(@types/node@20.19.43)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0) - zod: 4.6.5 + zod: 4.6.7 transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -40127,7 +40254,7 @@ snapshots: '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.18.0 acorn-import-phases: 1.0.4(acorn@8.18.0) - browserslist: 4.28.8 + browserslist: 4.29.0 chrome-trace-event: 1.0.2 enhanced-resolve: 5.24.0 es-module-lexer: 2.1.0 @@ -40167,7 +40294,7 @@ snapshots: '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.18.0 acorn-import-phases: 1.0.4(acorn@8.18.0) - browserslist: 4.28.8 + browserslist: 4.29.0 chrome-trace-event: 1.0.2 enhanced-resolve: 5.24.0 es-module-lexer: 2.1.0 @@ -40206,7 +40333,7 @@ snapshots: '@webassemblyjs/wasm-parser': 1.11.1 acorn: 8.18.0 acorn-import-assertions: 1.8.0(acorn@8.18.0) - browserslist: 4.28.8 + browserslist: 4.29.0 chrome-trace-event: 1.0.2 enhanced-resolve: 5.24.0 es-module-lexer: 0.9.3 @@ -40361,20 +40488,6 @@ snapshots: readable-stream: 3.6.2 triple-beam: 1.4.1 - winston@3.13.0: - dependencies: - '@colors/colors': 1.6.0 - '@dabh/diagnostics': 2.0.8 - async: 3.2.6 - is-stream: 2.0.1 - logform: 2.7.0 - one-time: 1.0.0 - readable-stream: 3.6.2 - safe-stable-stringify: 2.5.0 - stack-trace: 0.0.10 - triple-beam: 1.4.1 - winston-transport: 4.9.0 - winston@3.19.0: dependencies: '@colors/colors': 1.6.0 @@ -40626,6 +40739,10 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.2(zod@4.6.7): + dependencies: + zod: 4.6.7 + zod-to-ts@1.2.0(typescript@7.0.2)(zod@3.25.76): dependencies: typescript: 7.0.2 @@ -40633,7 +40750,7 @@ snapshots: zod@3.25.76: {} - zod@4.6.5: {} + zod@4.6.7: {} zone.js@0.12.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7416bffa6632..6a1a161a3dd0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -69,8 +69,6 @@ strictPeerDependencies: false # The packages depend on each other by exact version, not with `workspace:`. # This links them from the workspace instead of installing them from npm. linkWorkspacePackages: true -# A flat node_modules folder like Yarn 1, because some packages and configs import dependencies that they do not declare. -nodeLinker: hoisted overrides: nx>minimatch: 10.2.5 @@ -78,6 +76,25 @@ overrides: '@angular-devkit/build-angular>minimatch': 5.1.9 nitropack>rollup-plugin-visualizer: ^6.0.3 +# Dependencies that import packages without declaring them. pnpm links these peers to the versions of the importer. +packageExtensions: + # Its type declarations import the bundler types: https://github.com/apm-js-collab/code-transformer-bundler-plugins/issues/49 + '@apm-js-collab/code-transformer-bundler-plugins': + peerDependencies: + esbuild: '*' + rollup: '*' + vite: '*' + webpack: '*' + peerDependenciesMeta: + esbuild: + optional: true + rollup: + optional: true + vite: + optional: true + webpack: + optional: true + # Dependencies whose install scripts run. pnpm 11+ fails the install on build scripts that are not listed. # `false` only for scripts that just print a message. allowBuilds: @@ -99,3 +116,8 @@ allowBuilds: workerd: true minimumReleaseAgeExclude: - '@opentelemetry/api@1.10.0' + - '@sentry/conventions@0.29.0' + - '@sentry/rrdom@2.45.0' + - '@sentry/rrweb-snapshot@2.45.0' + - '@sentry/rrweb-types@2.45.0' + - '@sentry/rrweb@2.45.0' diff --git a/scripts/claude-cloud-setup.sh b/scripts/claude-cloud-setup.sh index 3659a65720d2..48fbe5620ee3 100755 --- a/scripts/claude-cloud-setup.sh +++ b/scripts/claude-cloud-setup.sh @@ -9,7 +9,7 @@ # cheap: a frozen-lockfile install is a no-op when node_modules is warm, and Nx # only rebuilds packages whose inputs actually changed. # -# Node/pnpm versions come from `package.json` (managed by Volta locally); in the +# Node/pnpm versions come from `package.json` (managed by pnpm locally); in the # cloud we rely on whatever Node runtime the sandbox provides, and install the # `packageManager` pnpm version when the sandbox has none. set -euo pipefail diff --git a/scripts/pr-review-reminder.mjs b/scripts/pr-review-reminder.mjs deleted file mode 100644 index e4886bde47ed..000000000000 --- a/scripts/pr-review-reminder.mjs +++ /dev/null @@ -1,321 +0,0 @@ -/** - * PR Review Reminder script. - * - * Posts reminder comments on open PRs whose requested reviewers have not - * responded within 2 business days. Re-nags every 2 business days thereafter - * until the review is submitted (or the request is removed). - * - * PRs are skipped (no reminder) when they are drafts, opened by a bot, already - * approved, or have an outstanding "changes requested" review (the PR is then - * waiting on the author, not the reviewers). - * - * @mentions are narrowed as follows: - * - Individual users: not [outside collaborators](https://docs.github.com/en/organizations/managing-outside-collaborators) - * on this repo (via `repos.listCollaborators` with `affiliation: outside` — repo-scoped, no extra token). - * - Team reviewers: only org teams in `SDK_TEAM_SLUGS` (by slug). - * - * Business days exclude weekends and a small set of recurring public holidays - * (same calendar date each year) for US, CA, and AT. - * - * Intended to be called from a GitHub Actions workflow via actions/github-script: - * - * const { default: run } = await import( - * `${process.env.GITHUB_WORKSPACE}/scripts/pr-review-reminder.mjs` - * ); - * await run({ github, context, core }); - */ - -// Team @mentions only for these slugs. Individuals are filtered using outside-collaborator list (see below). -const SDK_TEAM_SLUGS = new Set([ - 'team-javascript-sdks', - 'team-javascript-sdks-framework', - 'team-javascript-sdks-browser', - 'team-javascript-sdks-server', -]); - -// --------------------------------------------------------------------------- -// Outside collaborators (repo API — works with default GITHUB_TOKEN). -// Org members with access via teams or default permissions are not listed here. -// --------------------------------------------------------------------------- - -async function loadOutsideCollaboratorLogins(github, owner, repo, core) { - try { - const users = await github.paginate(github.rest.repos.listCollaborators, { - owner, - repo, - affiliation: 'outside', - per_page: 100, - }); - return new Set(users.map(u => u.login)); - } catch (e) { - const status = e.response?.status; - core.warning( - `Could not list outside collaborators for ${owner}/${repo} (${status ? `HTTP ${status}` : 'no status'}): ${e.message}. ` + - 'Skipping @mentions for individual reviewers (team reminders unchanged).', - ); - return null; - } -} - -// --------------------------------------------------------------------------- -// Recurring public holidays (month–day in UTC, same date every year). -// A calendar day counts as a holiday if it appears in any country list. -// --------------------------------------------------------------------------- - -const RECURRING_PUBLIC_HOLIDAYS_AT = [ - '01-01', - '01-06', - '05-01', - '08-15', - '10-26', - '11-01', - '12-08', - '12-24', - '12-25', - '12-26', - '12-31', -]; - -const RECURRING_PUBLIC_HOLIDAYS_CA = ['01-01', '07-01', '09-30', '11-11', '12-24', '12-25', '12-26', '12-31']; - -const RECURRING_PUBLIC_HOLIDAYS_US = ['01-01', '06-19', '07-04', '11-11', '12-24', '12-25', '12-26', '12-31']; - -const RECURRING_PUBLIC_HOLIDAY_MM_DD = new Set([ - ...RECURRING_PUBLIC_HOLIDAYS_AT, - ...RECURRING_PUBLIC_HOLIDAYS_CA, - ...RECURRING_PUBLIC_HOLIDAYS_US, -]); - -function monthDayUTC(date) { - const m = String(date.getUTCMonth() + 1).padStart(2, '0'); - const d = String(date.getUTCDate()).padStart(2, '0'); - return `${m}-${d}`; -} - -// --------------------------------------------------------------------------- -// Business-day counter. -// Counts fully-elapsed business days (Mon–Fri, not a public holiday) between -// requestedAt and now. "Fully elapsed" means the day has completely passed, -// so today is not included — giving the reviewer the rest of today to respond. -// -// Example: review requested Friday → elapsed complete days include Sat, Sun, -// Mon, Tue, … The first two business days are Mon and Tue, so the reminder -// fires on Wednesday morning. That gives the reviewer all of Monday and -// Tuesday to respond. -// --------------------------------------------------------------------------- - -function countElapsedBusinessDays(requestedAt, now) { - // Walk from the day after the request up to (but not including) today. - const start = new Date(requestedAt); - start.setUTCHours(0, 0, 0, 0); - start.setUTCDate(start.getUTCDate() + 1); - - const todayUTC = new Date(now); - todayUTC.setUTCHours(0, 0, 0, 0); - - let count = 0; - const cursor = new Date(start); - while (cursor < todayUTC) { - const dow = cursor.getUTCDay(); // 0 = Sun, 6 = Sat - if (dow !== 0 && dow !== 6) { - if (!RECURRING_PUBLIC_HOLIDAY_MM_DD.has(monthDayUTC(cursor))) { - count++; - } - } - cursor.setUTCDate(cursor.getUTCDate() + 1); - } - return count; -} - -// --------------------------------------------------------------------------- -// Reminder marker helpers -// --------------------------------------------------------------------------- - -// Returns a unique HTML comment marker for a reviewer key (login or "team:slug"). -// Used for precise per-reviewer deduplication in existing comments. -function reminderMarker(key) { - return ``; -} - -// --------------------------------------------------------------------------- -// Main entry point -// --------------------------------------------------------------------------- - -export default async function run({ github, context, core }) { - const { owner, repo } = context.repo; - const now = new Date(); - - core.info(`Using ${RECURRING_PUBLIC_HOLIDAY_MM_DD.size} recurring public holiday month–day values (US/CA/AT union)`); - - const outsideCollaboratorLogins = await loadOutsideCollaboratorLogins(github, owner, repo, core); - if (outsideCollaboratorLogins) { - core.info(`Excluding ${outsideCollaboratorLogins.size} outside collaborator login(s) from individual @mentions`); - } - - // --------------------------------------------------------------------------- - // Main loop - // --------------------------------------------------------------------------- - - // Fetch all open PRs - const prs = await github.paginate(github.rest.pulls.list, { - owner, - repo, - state: 'open', - per_page: 100, - }); - - core.info(`Found ${prs.length} open PRs`); - - for (const pr of prs) { - // Skip draft PRs and PRs opened by bots - if (pr.draft) continue; - if (pr.user?.type === 'Bot') continue; - - // Get currently requested reviewers (only those who haven't reviewed yet — - // GitHub automatically removes a reviewer from this list once they submit a review) - const { data: requested } = await github.rest.pulls.listRequestedReviewers({ - owner, - repo, - pull_number: pr.number, - }); - - const pendingReviewers = requested.users; // individual users - const pendingTeams = requested.teams; // team reviewers - if (pendingReviewers.length === 0 && pendingTeams.length === 0) continue; - - const reviews = await github.paginate(github.rest.pulls.listReviews, { - owner, - repo, - pull_number: pr.number, - per_page: 100, - }); - - // Skip if the PR already has at least one approval — no need to nudge remaining reviewers. - const hasApproval = reviews.some(r => r.state === 'APPROVED'); - if (hasApproval) continue; - - // Skip if any reviewer has an *outstanding* "changes requested" review — the ball is then in - // the author's court, not the reviewers', so nagging the reviewers is noise. - // - // A reviewer who is re-requested after requesting changes reappears in listRequestedReviewers - // (i.e. is back in `pendingReviewers`). Their earlier change-request is superseded by that new - // pending request, so it must NOT suppress reminders — otherwise we'd never nudge a reviewer - // the author has explicitly asked to take another look. We therefore ignore change-requests - // from currently-pending reviewers. (Dismissed reviews report state `DISMISSED`, so they no - // longer count either.) - const pendingReviewerLogins = new Set(pendingReviewers.map(u => u.login)); - const hasOutstandingChangesRequested = reviews.some( - r => r.state === 'CHANGES_REQUESTED' && !pendingReviewerLogins.has(r.user?.login), - ); - if (hasOutstandingChangesRequested) continue; - - // Fetch the PR timeline to determine when each review was (last) requested - const timeline = await github.paginate(github.rest.issues.listEventsForTimeline, { - owner, - repo, - issue_number: pr.number, - per_page: 100, - }); - - // Fetch existing comments so we can detect previous reminders - const comments = await github.paginate(github.rest.issues.listComments, { - owner, - repo, - issue_number: pr.number, - per_page: 100, - }); - - const botComments = comments.filter(c => c.user?.login === 'github-actions[bot]'); - - // Returns the date of the most recent reminder comment that contains the given marker, - // or null if no such comment exists. - function latestReminderDate(key) { - const marker = reminderMarker(key); - const matches = botComments - .filter(c => c.body.includes(marker)) - .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); - return matches.length > 0 ? new Date(matches[0].created_at) : null; - } - - // Returns true if a reminder is due for a reviewer/team: - // - The "anchor" is the later of: the review-request date, or the last - // reminder we already posted for this reviewer. This means the - // 2-business-day clock restarts after every reminder (re-nagging), and - // also resets when a new push re-requests the review. - // - A reminder fires when ≥ 2 full business days have elapsed since the anchor. - function needsReminder(requestedAt, key) { - const lastReminded = latestReminderDate(key); - const anchor = lastReminded && lastReminded > requestedAt ? lastReminded : requestedAt; - return countElapsedBusinessDays(anchor, now) >= 2; - } - - // Collect overdue individual reviewers - const toRemind = []; // { key, mention } - - for (const reviewer of pendingReviewers) { - const requestEvents = timeline - .filter(e => e.event === 'review_requested' && e.requested_reviewer?.login === reviewer.login) - .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); - - if (requestEvents.length === 0) { - core.warning( - `PR #${pr.number}: pending reviewer @${reviewer.login} has no matching review_requested timeline event; skipping reminder for them.`, - ); - continue; - } - - const requestedAt = new Date(requestEvents[0].created_at); - if (!needsReminder(requestedAt, reviewer.login)) continue; - - if (outsideCollaboratorLogins === null) { - continue; - } - if (outsideCollaboratorLogins.has(reviewer.login)) { - continue; - } - - toRemind.push({ key: reviewer.login, mention: `@${reviewer.login}` }); - } - - // Collect overdue team reviewers - for (const team of pendingTeams) { - if (!SDK_TEAM_SLUGS.has(team.slug)) { - continue; - } - - const requestEvents = timeline - .filter(e => e.event === 'review_requested' && e.requested_team?.slug === team.slug) - .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); - - if (requestEvents.length === 0) { - core.warning( - `PR #${pr.number}: pending team reviewer @${owner}/${team.slug} has no matching review_requested timeline event; skipping reminder for them.`, - ); - continue; - } - - const requestedAt = new Date(requestEvents[0].created_at); - const key = `team:${team.slug}`; - if (!needsReminder(requestedAt, key)) continue; - - toRemind.push({ key, mention: `@${owner}/${team.slug}` }); - } - - if (toRemind.length === 0) continue; - - // Build a single comment that includes per-reviewer markers (for precise dedup - // on subsequent runs) and @-mentions all overdue reviewers/teams. - const markers = toRemind.map(({ key }) => reminderMarker(key)).join('\n'); - const mentions = toRemind.map(({ mention }) => mention).join(', '); - const body = `${markers}\n👋 ${mentions} — Please review this PR when you get a chance!`; - - await github.rest.issues.createComment({ - owner, - repo, - issue_number: pr.number, - body, - }); - - core.info(`Posted review reminder on PR #${pr.number} for: ${mentions}`); - } -} diff --git a/scripts/verify-packages-versions.js b/scripts/verify-packages-versions.js index c45e2206ad9a..7268b26ba961 100644 --- a/scripts/verify-packages-versions.js +++ b/scripts/verify-packages-versions.js @@ -59,19 +59,4 @@ must contain the same packages in the same order. process.exit(1); } -// Configs import workspace packages that they do not declare, so every workspace package must be linked into the -// root node_modules folder, as Yarn did. -const missingRootDevDependencies = pkg.workspaces - .map(workspace => require(path.join(__dirname, '..', workspace, 'package.json')).name) - .filter(name => pkg.devDependencies[name] !== 'workspace:*'); - -if (missingRootDevDependencies.length > 0) { - console.error(` -[MISSING ROOT DEV DEPENDENCIES]: Every workspace package must be in the \`devDependencies\` of the root package.json -with the version \`workspace:*\`. Missing: -${missingRootDevDependencies.join('\n')} -`); - process.exit(1); -} - process.exit(0); diff --git a/scripts/weekly-flaky-tests.mjs b/scripts/weekly-flaky-tests.mjs index f3ab63d8016a..db00689db3ca 100644 --- a/scripts/weekly-flaky-tests.mjs +++ b/scripts/weekly-flaky-tests.mjs @@ -1,5 +1,8 @@ +import { mkdir, writeFile } from 'node:fs/promises'; + const LOOKBACK_DAYS = 7; const CONCURRENT_RUNS = 4; +const REPORT_DIR = 'flaky-tests-report'; function normalizeJobName(name) { return name @@ -183,6 +186,9 @@ export default async function run({ github, context, core }) { for (const warning of report.warnings) { core.warning(warning); } - await core.summary.addRaw(renderReport(report, context)).write(); + const markdown = renderReport(report, context); + await core.summary.addRaw(markdown).write(); + await mkdir(REPORT_DIR, { recursive: true }); + await writeFile(`${REPORT_DIR}/report.md`, markdown); return report; }