diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md
index 7091a161f177..afbd17c05ff9 100644
--- a/.cursor/BUGBOT.md
+++ b/.cursor/BUGBOT.md
@@ -103,6 +103,12 @@ Keep reviews high-signal. Prefer actionable, high-confidence findings over specu
- Only waiting for a request, after an action is performed. Instead, start waiting, perform action, await request promise.
- Race conditions when waiting on multiple requests. Ensure that waiting checks are unique enough and don't depend on a hard order when there's a chance that telemetry can be sent in arbitrary order.
- Timeouts or sleeps in tests. Instead suggest concrete events or other signals to wait on.
+- Flag cold module loading inside timed test callbacks or their helpers when
+ it can safely run during collection or setup. It can exhaust the timeout
+ before the behavior under test runs. Prefer static imports, narrower
+ modules, or setup hooks while preserving isolation and mock order.
+ Preserve imports whose evaluation timing is itself being tested.
+ Only flag concrete avoidable loading, not dynamic imports in general.
- Flag usage of `getFirstEnvelope*`, `getMultipleEnvelope*` or related test helpers in E2E tests. These are NOT reliable anymore. Instead suggest helpers like `waitForTransaction`, `waitForError`, `waitForSpans`, etc.
- Flag any new or modified `docker-compose.yml` under `dev-packages/node-integration-tests/suites/` where a service does not define a `healthcheck:`. The runner uses `docker compose up --wait` and relies on healthchecks to know when services are actually ready; without one the test will race the service's startup.
- Flag new Cloudflare tests that wrap the worker by hand with `withSentry`, `instrumentDurableObjectWithSentry`, `instrumentAgentWithSentry` or `instrumentWorkflowWithSentry`. This covers new suites under `dev-packages/cloudflare-integration-tests/suites/`, new E2E apps under `dev-packages/e2e-tests/test-applications/` that run on Cloudflare, and new Cloudflare variants of existing E2E apps (an entry in `sentryTest.variants` of the app's `package.json`, for example `hono-4 (cloudflare)`). App names do not always start with `cloudflare-`. Recommend the Sentry Cloudflare Vite plugin instead: `plugins: [cloudflare(), sentryCloudflareVitePlugin()]` in `vite.config.*` (`sentryCloudflareVitePlugin` from `@sentry/cloudflare/vite`), and the SDK options in an `instrument.server.ts` next to the worker entry that default-exports `defineCloudflareOptions(...)`. Do not flag tests whose purpose is to test one of these wrappers, or apps that do not build with Vite (for example Next.js on OpenNext).
@@ -112,6 +118,7 @@ Keep reviews high-signal. Prefer actionable, high-confidence findings over specu
- When any `setTimeout` or `setInterval` timers are started in a code path that can end up in server runtime packages (e.g. `@sentry/core` or `@sentry/node`), flag if neither `timeout.unref()` nor `safeUnref()` are called.
Not unref'ing a timer can keep CLI-like applications or node scripts from exiting immediately, due to the process waiting on timers started by the SDK.
- Flag Node-only imports (`fs`, `path`, `child_process`, `os`, `net`, `http`, `https`, `node:*`, etc.) in code paths that ship to non-Node runtimes (`@sentry/browser`, `@sentry/cloudflare`, `@sentry/deno`, `@sentry/bun`, or shared code in `@sentry/core` / `@sentry/browser-utils` reachable from those entry points). When the dependency is unavoidable, isolate it behind a runtime check, dynamic `import()`, or a Node-only entry point — never at the top level of a cross-runtime module.
+- Flag a new package, or a new `exports` entry in a `package.json` under `packages/`, that can load on a server runtime (Node, Bun, Deno, Cloudflare Workers, Vercel Edge) when its package has no entry in `CASES` or `BUILD_TIME_CASES` in `dev-packages/bundler-tests/tests/runtime-entries.test.ts`. That test checks that server runtime entries do not reach build-time tooling (bundler plugins, Vite, Babel, WASM). For packages it already covers, the test fails on its own when an export is not classified, so do not flag those. Recommend adding the package to `devDependencies` in `dev-packages/bundler-tests/package.json`, and each export to `CASES` (runtime), `BUILD_TIME_CASES` (build-time), or `CLIENT_ENTRIES` (browser). Do not flag browser-only packages.
## What NOT to Flag
diff --git a/.github/actions/install-dependencies/action.yml b/.github/actions/install-dependencies/action.yml
index 2a4b331fd3f6..5f51f918c34b 100644
--- a/.github/actions/install-dependencies/action.yml
+++ b/.github/actions/install-dependencies/action.yml
@@ -1,64 +1,30 @@
name: 'Install pnpm dependencies'
-description: 'Installs pnpm dependencies into node_modules folders on the Namespace cache volume.'
+description: 'Installs pnpm dependencies and caches them.'
+
+outputs:
+ cache_key:
+ description: 'The dependency cache key'
+ value: ${{ steps.compute_lockfile_hash.outputs.hash }}
runs:
using: 'composite'
steps:
- - name: List dependency paths
- id: paths
- run: |
- {
- echo 'paths<> "$GITHUB_OUTPUT"
- shell: bash
-
- - name: Mount dependency cache
- uses: ./.github/actions/mount-pnpm-store
- with:
- path: ${{ steps.paths.outputs.paths }}
-
- name: Install pnpm
uses: ./.github/actions/install-pnpm
- # When the node_modules layout changes, `pnpm install` keeps the `.bin` scripts of the workspace packages, which
- # then point into a folder that the new layout does not have. Branches that change `nodeLinker` share this cache
- # volume, so start from empty node_modules folders when the layout on the volume differs, or when an earlier
- # install already left such broken `.bin` scripts behind.
- - name: Remove node_modules of another layout
- run: |
- mapfile -t dirs < <(grep '/node_modules$' <<< "$DEPENDENCY_PATHS")
- expected=$(pnpm config get node-linker)
- if [[ "$expected" == undefined ]]; then
- expected=isolated
- fi
- actual=$(node -p 'try { JSON.parse(require("fs").readFileSync("node_modules/.modules.yaml", "utf8")).nodeLinker ?? "" } catch { "" }')
- if [[ -n "$actual" && "$actual" != "$expected" ]]; then
- reason="The node_modules folders use the $actual layout, but nodeLinker is $expected."
- else
- broken=$(node "$GITHUB_ACTION_PATH/find-broken-bin.mjs" "${dirs[@]}")
- if [[ -n "$broken" ]]; then
- reason="The target of $broken does not exist."
- fi
- fi
- if [[ -n "$reason" ]]; then
- echo "$reason Removing the node_modules folders."
- for dir in "${dirs[@]}"; do
- if [[ -d "$dir" ]]; then
- # The folders are mounted from the cache volume, so empty them instead of removing them
- find "$dir/" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
- fi
- done
- fi
- env:
- DEPENDENCY_PATHS: ${{ steps.paths.outputs.paths }}
+ - name: Compute dependency cache key
+ id: compute_lockfile_hash
+ run: node ./scripts/dependency-hash-key.js >> "$GITHUB_OUTPUT"
shell: bash
- # The cache volume can hold node_modules of another commit or branch. This is a no-op when they match
- # pnpm-lock.yaml.
+ - name: Check dependency cache
+ uses: actions/cache@v6.1.0
+ id: cache_dependencies
+ with:
+ path: ${{ env.CACHED_DEPENDENCY_PATHS }}
+ key: ${{ steps.compute_lockfile_hash.outputs.hash }}
+
- name: Install dependencies
+ if: steps.cache_dependencies.outputs.cache-hit != 'true'
run: pnpm install --frozen-lockfile
shell: bash
diff --git a/.github/actions/install-dependencies/find-broken-bin.mjs b/.github/actions/install-dependencies/find-broken-bin.mjs
deleted file mode 100644
index 24aa8753cac5..000000000000
--- a/.github/actions/install-dependencies/find-broken-bin.mjs
+++ /dev/null
@@ -1,34 +0,0 @@
-// Prints the first `.bin` entry in the given node_modules folders whose target does not exist.
-import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs';
-import { join, resolve } from 'node:path';
-
-// The pnpm shell shim runs the target with `exec "$basedir_abs/"` (`$basedir` in older versions)
-const SHIM_TARGET = /^\s*exec \S+\s+"\$basedir(?:_abs)?\/([^"]+)"/m;
-
-for (const dir of process.argv.slice(2)) {
- const binDir = join(dir, '.bin');
- let names;
- try {
- names = readdirSync(binDir);
- } catch {
- continue;
- }
-
- for (const name of names) {
- const bin = join(binDir, name);
- const stat = lstatSync(bin);
- let target;
- if (stat.isSymbolicLink()) {
- target = bin;
- } else if (stat.isFile() && stat.size < 64 * 1024) {
- const match = readFileSync(bin, 'utf8').match(SHIM_TARGET);
- target = match && resolve(binDir, match[1]);
- }
-
- if (target && !existsSync(target)) {
- // eslint-disable-next-line no-console
- console.log(bin);
- process.exit(0);
- }
- }
-}
diff --git a/.github/actions/install-playwright/action.yml b/.github/actions/install-playwright/action.yml
index 378e77666093..ca6c8a2d9583 100644
--- a/.github/actions/install-playwright/action.yml
+++ b/.github/actions/install-playwright/action.yml
@@ -1,5 +1,5 @@
name: 'Install Playwright dependencies'
-description: 'Installs Playwright browsers and system dependencies, keeping both on the Namespace cache volume.'
+description: 'Installs Playwright dependencies and caches them.'
inputs:
browsers:
description: 'What browsers to install.'
@@ -26,28 +26,43 @@ runs:
shell: bash
working-directory: ${{ inputs.cwd }}
- - name: Mount Playwright cache
- uses: namespacelabs/nscloud-cache-action@1124a6f3ce44e5cf84cc22111530961f4d2a15f9 # v1.7.0
+ - name: Restore cached playwright binaries
+ uses: actions/cache/restore@v6.1.0
+ id: playwright-cache
if: steps.playwright-version.outputs.version != ''
with:
- cache: |
- playwright
- apt
+ path: |
+ ~/.cache/ms-playwright
+ # Bump the iteration when bumping runner images to use a new cache
+ key: playwright-${{ runner.os }}-iteration-1-${{ steps.playwright-version.outputs.version }}
- # `--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
+ # We always install all browsers, if uncached
+ # `npx` runs outside the repo, so it can't pick up a `playwright` that a parallel `pnpm install` is still linking.
+ - name: Install Playwright dependencies (uncached)
+ env:
+ PLAYWRIGHT_VERSION: ${{ steps.playwright-version.outputs.version }}
+ run: npx -y "playwright@$PLAYWRIGHT_VERSION" install chromium webkit firefox --with-deps
+ if: steps.playwright-version.outputs.version != '' && steps.playwright-cache.outputs.cache-hit != 'true'
shell: bash
+ working-directory: ${{ runner.temp }}
- # Downloads only the browsers and apt packages that are not on the cache volume yet. `npx` runs outside the repo,
- # so it can't pick up a `playwright` that a parallel `pnpm install` is still linking.
- - name: Install Playwright
- if: steps.playwright-version.outputs.version != ''
+ - name: Install Playwright system dependencies only (cached)
env:
PLAYWRIGHT_BROWSERS: ${{ inputs.browsers || 'chromium webkit firefox' }}
PLAYWRIGHT_VERSION: ${{ steps.playwright-version.outputs.version }}
- run: npx -y "playwright@$PLAYWRIGHT_VERSION" install --with-deps $PLAYWRIGHT_BROWSERS
+ run: npx -y "playwright@$PLAYWRIGHT_VERSION" install-deps $PLAYWRIGHT_BROWSERS
+ if: steps.playwright-version.outputs.version != '' && steps.playwright-cache.outputs.cache-hit == 'true'
shell: bash
working-directory: ${{ runner.temp }}
+
+ # Only store cache on develop branch
+ - name: Store cached playwright binaries
+ uses: actions/cache/save@v6.1.0
+ if:
+ steps.playwright-version.outputs.version != '' && github.event_name == 'push' && github.ref ==
+ 'refs/heads/develop'
+ with:
+ path: |
+ ~/.cache/ms-playwright
+ # Bump the iteration when bumping runner images to use a new cache
+ key: playwright-${{ runner.os }}-iteration-1-${{ steps.playwright-version.outputs.version }}
diff --git a/.github/actions/mount-pnpm-store/action.yml b/.github/actions/mount-pnpm-store/action.yml
deleted file mode 100644
index fed4e40c1b73..000000000000
--- a/.github/actions/mount-pnpm-store/action.yml
+++ /dev/null
@@ -1,33 +0,0 @@
-name: 'Mount pnpm store'
-description:
- 'Mounts the pnpm store, the pnpm and npm caches and the given paths from the Namespace cache volume. Use it before
- `install-pnpm`, so `install-pnpm` takes pnpm from the cache volume.'
-
-inputs:
- path:
- description: 'Paths to mount from the cache volume in addition to the pnpm store and caches, one per line.'
- required: false
- default: ''
-
-runs:
- using: 'composite'
- steps:
- # Replaces the `pnpm` mode of nscloud-cache-action until it works without pnpm on PATH. The mode runs `pnpm store
- # path`, so it only works after `install-pnpm`, when `pnpm self-update` has already downloaded pnpm.
- # The cache directory holds `lockfile-verified.jsonl`. With it, `pnpm install` skips the supply-chain check of a
- # lockfile that was already verified, which otherwise fetches the metadata of every package from the registry.
- - name: Set pnpm store and cache directories
- run: |
- echo "PNPM_CONFIG_STORE_DIR=$HOME/.cache/pnpm-store" >> "$GITHUB_ENV"
- echo "PNPM_CONFIG_CACHE_DIR=$HOME/.cache/pnpm" >> "$GITHUB_ENV"
- shell: bash
-
- # The `npm` mode keeps the npm cache of the `npm ci` in `pnpm/action-setup`, which installs the bootstrap pnpm
- - name: Mount cache paths
- uses: namespacelabs/nscloud-cache-action@1124a6f3ce44e5cf84cc22111530961f4d2a15f9 # v1.7.0
- with:
- cache: npm
- path: |
- ~/.cache/pnpm-store
- ~/.cache/pnpm
- ${{ inputs.path }}
diff --git a/.github/actions/prepare-e2e-test/action.yml b/.github/actions/prepare-e2e-test/action.yml
index 4a4c8a012026..6af9d8ce37a6 100644
--- a/.github/actions/prepare-e2e-test/action.yml
+++ b/.github/actions/prepare-e2e-test/action.yml
@@ -46,9 +46,6 @@ runs:
uses: denoland/setup-deno@v2.0.5
with:
deno-version: ${{ inputs.deno-version || 'v2.8.3' }}
- # Keeps the pnpm store on the cache volume for the `pnpm install` of the test application
- - name: Mount pnpm store
- uses: ./.github/actions/mount-pnpm-store
- name: Install pnpm
uses: ./.github/actions/install-pnpm
diff --git a/.github/actions/restore-cache/action.yml b/.github/actions/restore-cache/action.yml
index c647c74405b9..daa5943f9877 100644
--- a/.github/actions/restore-cache/action.yml
+++ b/.github/actions/restore-cache/action.yml
@@ -1,13 +1,30 @@
name: 'Restore dependency & build cache'
-description: 'Installs dependencies from the Namespace cache volume and restores the build output.'
+description: 'Restore the dependency & build cache.'
+
+inputs:
+ dependency_cache_key:
+ description: 'The dependency cache key'
+ required: true
runs:
using: 'composite'
steps:
+ - name: Install pnpm
+ uses: ./.github/actions/install-pnpm
+
+ - name: Check dependency cache
+ id: dep-cache
+ uses: actions/cache/restore@v6.1.0
+ with:
+ path: ${{ env.CACHED_DEPENDENCY_PATHS }}
+ key: ${{ inputs.dependency_cache_key }}
+
- name: Restore build artifacts
uses: actions/download-artifact@v7
with:
name: build-output
- name: Install dependencies
- uses: ./.github/actions/install-dependencies
+ if: steps.dep-cache.outputs.cache-hit != 'true'
+ run: pnpm install --frozen-lockfile
+ shell: bash
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index f0fddbffa61e..87a7ed005b20 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -11,30 +11,120 @@ updates:
- package-ecosystem: 'npm'
directory: '/'
schedule:
- interval: 'weekly'
+ interval: 'daily'
ignore:
- - dependency-name: '@opentelemetry/instrumentation'
- - dependency-name: '@opentelemetry/instrumentation-*'
- dependency-name: 'typescript'
- # 2.110.0 raised the engine requirement to Node >=22, while this repo runs Node 20.
- - dependency-name: '@supabase/supabase-js'
- versions: ['>=2.110.0']
+ # Keep shared Prisma dependencies on v6; v7 has a dedicated integration suite.
+ - dependency-name: 'prisma'
+ update-types: ['version-update:semver-major']
+ - dependency-name: '@prisma/client'
+ update-types: ['version-update:semver-major']
+ # Major upgrades of SDK targets and coupled tooling require an explicit compatibility decision.
+ - dependency-name: '@angular/*'
+ update-types: ['version-update:semver-major']
+ - dependency-name: '@angular-devkit/*'
+ update-types: ['version-update:semver-major']
+ - dependency-name: 'ng-packagr'
+ update-types: ['version-update:semver-major']
+ - dependency-name: 'astro'
+ update-types: ['version-update:semver-major']
+ - dependency-name: 'ember-source'
+ update-types: ['version-update:semver-major']
+ - dependency-name: 'effect'
+ update-types: ['version-update:semver-major']
+ - dependency-name: 'next'
+ update-types: ['version-update:semver-major']
+ - dependency-name: 'nuxt'
+ update-types: ['version-update:semver-major']
+ - dependency-name: '@nuxt/kit'
+ update-types: ['version-update:semver-major']
+ - dependency-name: '@nuxt/schema'
+ update-types: ['version-update:semver-major']
+ - dependency-name: '@nuxt/nitro-server'
+ update-types: ['version-update:semver-major']
+ - dependency-name: 'react'
+ update-types: ['version-update:semver-major']
+ - dependency-name: 'react-dom'
+ update-types: ['version-update:semver-major']
+ - dependency-name: '@types/react'
+ update-types: ['version-update:semver-major']
+ - dependency-name: '@types/express'
+ update-types: ['version-update:semver-major']
+ - dependency-name: 'react-router'
+ update-types: ['version-update:semver-major']
+ - dependency-name: '@react-router/*'
+ update-types: ['version-update:semver-major']
+ - dependency-name: '@remix-run/node'
+ update-types: ['version-update:semver-major']
+ - dependency-name: '@remix-run/react'
+ update-types: ['version-update:semver-major']
+ - dependency-name: '@remix-run/server-runtime'
+ update-types: ['version-update:semver-major']
+ - dependency-name: '@remix-run/router'
+ update-types: ['version-update:semver-major']
+ - dependency-name: '@solidjs/router'
+ update-types: ['version-update:semver-major']
+ - dependency-name: '@solidjs/start'
+ update-types: ['version-update:semver-major']
+ - dependency-name: 'svelte'
+ update-types: ['version-update:semver-major']
+ - dependency-name: '@sveltejs/kit'
+ update-types: ['version-update:semver-major']
+ - dependency-name: 'vue'
+ update-types: ['version-update:semver-major']
+ - dependency-name: '@nestjs/common'
+ update-types: ['version-update:semver-major']
+ - dependency-name: '@nestjs/core'
+ update-types: ['version-update:semver-major']
+ - dependency-name: 'hono'
+ update-types: ['version-update:semver-major']
+ - dependency-name: 'nitro'
+ update-types: ['version-update:semver-major']
+ - dependency-name: 'nitropack'
+ update-types: ['version-update:semver-major']
+ - dependency-name: 'h3'
+ update-types: ['version-update:semver-major']
+ - dependency-name: '@cloudflare/workers-types'
+ update-types: ['version-update:semver-major']
+ - dependency-name: 'wrangler'
+ update-types: ['version-update:semver-major']
+ - dependency-name: 'bun-types'
+ update-types: ['version-update:semver-major']
+ - dependency-name: '@types/node'
+ update-types: ['version-update:semver-major']
+ - dependency-name: '@edge-runtime/types'
+ update-types: ['version-update:semver-major']
+ - dependency-name: '@opentelemetry/api'
+ update-types: ['version-update:semver-major']
+ - dependency-name: 'vite'
+ update-types: ['version-update:semver-major']
+ - dependency-name: 'webpack'
+ update-types: ['version-update:semver-major']
+ # These are also peer dependencies. The `increase` strategy raises the peer range too,
+ # which drops support for older versions (a breaking change).
+ - dependency-name: '@tanstack/vue-router'
+ - dependency-name: '@tanstack/solid-router'
+ - dependency-name: 'solid-js'
+ - dependency-name: 'elysia'
+ # node-integration-tests pins langchain v0 to test the lowest supported versions.
+ # Dependabot uses one version per workspace, so it would also change the v1 pins in cloudflare-integration-tests.
+ - dependency-name: 'langchain'
+ - dependency-name: '@langchain/*'
groups:
- opentelemetry:
+ dependencies:
patterns:
- - '@opentelemetry/*'
- remix:
- patterns:
- - '@remix-run/*'
- react-router:
- patterns:
- - 'react-router'
- - 'react-router-dom'
- - '@react-router/*'
+ - '*'
versioning-strategy: increase
commit-message:
prefix: feat
prefix-development: chore
include: scope
exclude-paths:
+ - 'dev-packages/browser-integration-tests/**'
+ - 'dev-packages/bundler-plugin-integration-tests/**'
+ - 'dev-packages/bundler-tests/**'
+ - 'dev-packages/bun-integration-tests/**'
+ - 'dev-packages/node-integration-tests/**'
+ - 'dev-packages/deno-integration-tests/**'
+ - 'dev-packages/cloudflare-integration-tests/**'
- 'dev-packages/e2e-tests/test-applications/**'
diff --git a/.github/workflows/auto-fix-issue.yml b/.github/workflows/auto-fix-issue.yml
index 8feb478c6edb..f495e8bc8e6b 100644
--- a/.github/workflows/auto-fix-issue.yml
+++ b/.github/workflows/auto-fix-issue.yml
@@ -23,7 +23,7 @@ concurrency:
jobs:
auto-fix-issue:
- runs-on: namespace-profile-sentry-javascript
+ runs-on: ubuntu-latest
environment: ci-triage
permissions:
# Required to create a new branch and commit the fix
@@ -77,7 +77,7 @@ jobs:
- name: Try to fix the issue with Claude
id: triage
- uses: anthropics/claude-code-action@8ce9314fa9a404564fa7e954cd84f25bcba2b829 # v1
+ uses: anthropics/claude-code-action@d535f5172dec02b204866b393d6b20b9b2aaf98d # v1
env:
ANTHROPIC_BASE_URL: https://openrouter.ai/api
with:
diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml
index 38802d1c0ad6..ab50a76d818b 100644
--- a/.github/workflows/auto-release.yml
+++ b/.github/workflows/auto-release.yml
@@ -13,7 +13,7 @@ permissions:
jobs:
release:
- runs-on: namespace-profile-sentry-javascript-small
+ runs-on: ubuntu-24.04
name: 'Prepare a new version'
steps:
@@ -55,7 +55,7 @@ jobs:
node-version-file: 'package.json'
- name: Prepare release
- uses: getsentry/craft@25028d0646040cc0a669ae3314cbf884f4c4347a # v2.31.2
+ uses: getsentry/craft@3f1f02d50b08b8c509a28d2cc2abf594e9a28011 # v3.5.0
if:
github.event.pull_request.merged == true && steps.version-regex.outputs.match != '' &&
steps.get_version.outputs.version != ''
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index f6d386c40b43..9c408c806846 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -29,6 +29,12 @@ concurrency:
env:
HEAD_COMMIT: ${{ github.event.inputs.commit || github.sha }}
+ CACHED_DEPENDENCY_PATHS: |
+ ${{ github.workspace }}/node_modules
+ ${{ github.workspace }}/packages/*/node_modules
+ ${{ github.workspace }}/dev-packages/*/node_modules
+ ~/.cache/mongodb-binaries/
+
# build-output artifact paths are computed in job_build (step output from
# pnpm run ci:print-build-artifact-paths — Nx merged outputs for build:transpile, and build:types).
@@ -52,11 +58,8 @@ env:
# This option both controls the `v8-compile-cache-lib` and `v8-compile-cache` packages.
DISABLE_V8_COMPILE_CACHE: '1'
-# Jobs that install the monorepo dependencies share one Namespace cache volume through `overrides.cache-tag`, E2E jobs
-# share another one (`-e2e`). The optional Cloudflare integration tests replace the installed wrangler with the latest
-# one, so they use a third one (`-latest-wrangler`). Jobs that do not use a cache volume have no cache tag. Pull request
-# jobs get their own volumes (`-pr`), so they cannot change the node_modules that develop and release builds install
-# from.
+# Jobs that need the monorepo dependencies restore them from the GitHub Actions cache (see `restore-cache`),
+# keyed by the lockfile hash. E2E jobs install their test application separately and do not use that cache.
jobs:
job_get_metadata:
uses: ./.github/workflows/ci-metadata.yml
@@ -69,9 +72,7 @@ jobs:
job_build:
name: Build
needs: job_get_metadata
- runs-on:
- namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name ==
- 'pull_request' && '-pr' || '' }}
+ runs-on: ubuntu-24.04
timeout-minutes: 15
if: |
needs.job_get_metadata.outputs.changed_any_code == 'true' ||
@@ -97,6 +98,7 @@ jobs:
- name: Install Dependencies
uses: ./.github/actions/install-dependencies
+ id: install_dependencies
- name: Check for Affected Nx Projects
uses: ./.github/actions/nx-affected-list
@@ -140,7 +142,7 @@ jobs:
- name: Determine which test applications should be run
id: matrix
run:
- pnpm --silent run ci:build-matrix --base=${{ (github.event_name == 'pull_request' &&
+ pnpm --silent run ci:build-matrix --shards=2 --base=${{ (github.event_name == 'pull_request' &&
needs.job_get_metadata.outputs.changed_ci != 'true' && github.event.pull_request.base.sha) || '' }} >>
$GITHUB_OUTPUT
working-directory: dev-packages/e2e-tests
@@ -154,6 +156,7 @@ jobs:
working-directory: dev-packages/e2e-tests
outputs:
+ dependency_cache_key: ${{ steps.install_dependencies.outputs.cache_key }}
changed_node_integration:
${{ needs.job_get_metadata.outputs.changed_ci == 'true' || contains(steps.checkForAffected.outputs.affected,
'@sentry-internal/node-integration-tests') }}
@@ -184,19 +187,19 @@ jobs:
changed_aws_serverless:
${{ needs.job_get_metadata.outputs.changed_ci == 'true' || contains(steps.checkForAffected.outputs.affected,
'@sentry/aws-serverless') }}
- e2e-matrix: ${{ steps.matrix.outputs.matrix }}
+ e2e-matrix-1: ${{ steps.matrix.outputs.matrix-1 }}
+ e2e-matrix-2: ${{ steps.matrix.outputs.matrix-2 }}
e2e-matrix-optional: ${{ steps.matrix-optional.outputs.matrix }}
job_build_layer:
name: Build Lambda layer
needs: [job_get_metadata, job_build]
if:
- needs.job_build.outputs.changed_aws_serverless == 'true' || contains(needs.job_build.outputs.e2e-matrix,
- 'aws-serverless') || github.event_name != 'pull_request'
+ needs.job_build.outputs.changed_aws_serverless == 'true' || contains(needs.job_build.outputs.e2e-matrix-1,
+ 'aws-serverless') || contains(needs.job_build.outputs.e2e-matrix-2, 'aws-serverless') || github.event_name !=
+ 'pull_request'
timeout-minutes: 10
- runs-on:
- namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request'
- && '-pr' || '' }}
+ runs-on: ubuntu-24.04
steps:
- name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }})
uses: actions/checkout@v7
@@ -216,6 +219,8 @@ jobs:
- name: Restore caches
uses: ./.github/actions/restore-cache
+ with:
+ dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }}
- name: Build Lambda layer
run: pnpm run build:layer
@@ -233,9 +238,7 @@ jobs:
name: Build bundles
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' || '' }}
+ runs-on: ubuntu-24.04
steps:
- name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }})
uses: actions/checkout@v7
@@ -255,6 +258,8 @@ jobs:
- name: Restore caches
uses: ./.github/actions/restore-cache
+ with:
+ dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }}
- name: Build bundles
run: pnpm run build:bundle
@@ -271,7 +276,7 @@ jobs:
job_check_branches:
name: Check PR branches
needs: job_get_metadata
- runs-on: namespace-profile-sentry-javascript-small
+ runs-on: ubuntu-24.04
if: github.event_name == 'pull_request'
permissions:
pull-requests: write
@@ -287,9 +292,7 @@ jobs:
name: Size Check
needs: [job_get_metadata, job_build, job_build_bundles]
timeout-minutes: 15
- runs-on:
- namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request'
- && '-pr' || '' }}
+ runs-on: ubuntu-24.04
if:
github.event_name == 'pull_request' || needs.job_get_metadata.outputs.is_base_branch == 'true' ||
needs.job_get_metadata.outputs.is_release == 'true'
@@ -304,6 +307,8 @@ jobs:
node-version-file: 'package.json'
- name: Restore caches
uses: ./.github/actions/restore-cache
+ with:
+ dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }}
- name: Restore build bundle artifacts
uses: actions/download-artifact@v8
with:
@@ -322,9 +327,7 @@ jobs:
# inter-package dependencies resolve cleanly.
needs: [job_get_metadata, job_build]
timeout-minutes: 10
- runs-on:
- namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name ==
- 'pull_request' && '-pr' || '' }}
+ runs-on: ubuntu-24.04
steps:
- name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }})
uses: actions/checkout@v7
@@ -336,6 +339,8 @@ jobs:
node-version-file: 'package.json'
- name: Restore caches
uses: ./.github/actions/restore-cache
+ with:
+ dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }}
- parallel:
- name: Lint source files
run: pnpm run lint
@@ -364,7 +369,7 @@ jobs:
needs: [job_get_metadata]
# `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
+ runs-on: ubuntu-24.04
if: |
needs.job_get_metadata.outputs.changed_deps == 'true' ||
needs.job_get_metadata.outputs.is_base_branch == 'true' ||
@@ -395,9 +400,7 @@ jobs:
name: Check file formatting
needs: [job_get_metadata]
timeout-minutes: 10
- runs-on:
- namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request'
- && '-pr' || '' }}
+ runs-on: ubuntu-24.04
steps:
- name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }})
uses: actions/checkout@v7
@@ -418,9 +421,7 @@ jobs:
job_artifacts:
name: Upload Artifacts
needs: [job_get_metadata, job_build, job_build_layer, job_build_bundles, job_build_tarballs]
- runs-on:
- namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request'
- && '-pr' || '' }}
+ runs-on: ubuntu-24.04
# Build artifacts are only needed for releasing workflow.
if: needs.job_get_metadata.outputs.is_release == 'true'
steps:
@@ -434,6 +435,8 @@ jobs:
node-version-file: 'package.json'
- name: Restore caches
uses: ./.github/actions/restore-cache
+ with:
+ dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }}
- name: Restore tarball artifacts
uses: actions/download-artifact@v8
@@ -472,9 +475,7 @@ jobs:
name: Browser Unit Tests
needs: [job_get_metadata, job_build]
timeout-minutes: 10
- runs-on:
- namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name ==
- 'pull_request' && '-pr' || '' }}
+ runs-on: ubuntu-24.04
steps:
- name: Check out base commit (${{ github.event.pull_request.base.sha }})
uses: actions/checkout@v7
@@ -492,6 +493,8 @@ jobs:
node-version-file: 'package.json'
- name: Restore caches
uses: ./.github/actions/restore-cache
+ with:
+ dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }}
- name: Run affected tests
run: pnpm run test:pr:browser --base=${{ github.event.pull_request.base.sha }}
@@ -506,9 +509,7 @@ jobs:
needs: [job_get_metadata, job_build]
if: needs.job_build.outputs.changed_bun == 'true' || github.event_name != 'pull_request'
timeout-minutes: 10
- runs-on:
- namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request'
- && '-pr' || '' }}
+ runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
@@ -529,6 +530,8 @@ jobs:
bun-version: ${{ matrix.bun }}
- name: Restore caches
uses: ./.github/actions/restore-cache
+ with:
+ dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }}
- name: Run tests
run: |
pnpm run test:ci:bun
@@ -538,9 +541,7 @@ jobs:
needs: [job_get_metadata, job_build]
if: needs.job_build.outputs.changed_deno == 'true' || github.event_name != 'pull_request'
timeout-minutes: 10
- runs-on:
- namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request'
- && '-pr' || '' }}
+ runs-on: ubuntu-24.04
steps:
- name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }})
uses: actions/checkout@v7
@@ -553,9 +554,11 @@ jobs:
- name: Set up Deno
uses: denoland/setup-deno@v2.0.5
with:
- deno-version: ${{ matrix.deno-version || 'v2.8.3' }}
+ deno-version: 'v2.8.3'
- name: Restore caches
uses: ./.github/actions/restore-cache
+ with:
+ dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }}
- name: Run tests
run: |
cd packages/deno
@@ -566,13 +569,13 @@ jobs:
name: Node (${{ matrix.node }}) Unit Tests
needs: [job_get_metadata, job_build]
timeout-minutes: 10
- runs-on:
- namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name ==
- 'pull_request' && '-pr' || '' }}
+ runs-on: ubuntu-24.04
+ # Node 27 is an alpha: its ABI and APIs can change, so its failures must not fail the required checks.
+ continue-on-error: ${{ matrix.node == '^27.0.0-0' }}
strategy:
fail-fast: false
matrix:
- node: [20.19, 22, 24, 26]
+ node: [20.19, 22, 24, 26, '^27.0.0-0']
steps:
- name: Check out base commit (${{ github.event.pull_request.base.sha }})
uses: actions/checkout@v7
@@ -589,6 +592,13 @@ jobs:
node-version: ${{ matrix.node }}
- name: Restore caches
uses: ./.github/actions/restore-cache
+ with:
+ dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }}
+ # The native modules have no prebuilt binaries for the Node 27 alpha, so compile them like an install on a
+ # user's machine does.
+ - name: Compile native modules
+ if: matrix.node == '^27.0.0-0'
+ run: pnpm rebuild @sentry/node-cpu-profiler @sentry/node-native-stacktrace
- name: Run affected tests
run: pnpm run test:pr:node --base=${{ github.event.pull_request.base.sha }}
@@ -608,9 +618,7 @@ jobs:
matrix.project) || ''}}${{ matrix.shard && format(' ({0}/{1})', matrix.shard, matrix.shards) || ''}} Tests
needs: [job_get_metadata, job_build, job_build_bundles]
if: needs.job_build.outputs.changed_browser_integration == 'true' || github.event_name != 'pull_request'
- runs-on:
- namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name ==
- 'pull_request' && '-pr' || '' }}
+ runs-on: ubuntu-24.04-large-js
timeout-minutes: 25
strategy:
fail-fast: false
@@ -670,6 +678,8 @@ jobs:
node-version-file: 'package.json'
- name: Restore caches
uses: ./.github/actions/restore-cache
+ with:
+ dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }}
- name: Restore build bundle artifacts
uses: actions/download-artifact@v8
@@ -706,9 +716,7 @@ jobs:
name: PW ${{ matrix.bundle }} Tests
needs: [job_get_metadata, job_build, job_build_bundles]
if: needs.job_build.outputs.changed_browser_integration == 'true' || github.event_name != 'pull_request'
- runs-on:
- namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request'
- && '-pr' || '' }}
+ runs-on: ubuntu-24.04
timeout-minutes: 15
strategy:
fail-fast: false
@@ -733,6 +741,8 @@ jobs:
node-version-file: 'package.json'
- name: Restore caches
uses: ./.github/actions/restore-cache
+ with:
+ dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }}
- name: Restore build bundle artifacts
uses: actions/download-artifact@v8
@@ -766,14 +776,14 @@ jobs:
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:
- namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name ==
- 'pull_request' && '-pr' || '' }}
+ runs-on: ubuntu-24.04
timeout-minutes: 20
+ # Node 27 is an alpha: its ABI and APIs can change, so its failures must not fail the required checks.
+ continue-on-error: ${{ matrix.node == '^27.0.0-0' }}
strategy:
fail-fast: false
matrix:
- node: [20.19, 22, 24, 26]
+ node: [20.19, 22, 24, 26, '^27.0.0-0']
shard: [1, 2, 3]
steps:
- name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }})
@@ -799,6 +809,13 @@ jobs:
node-version: ${{ matrix.node }}
- name: Restore caches
uses: ./.github/actions/restore-cache
+ with:
+ dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }}
+ # The native modules have no prebuilt binaries for the Node 27 alpha, so compile them like an install on a
+ # user's machine does.
+ - name: Compile native modules
+ if: matrix.node == '^27.0.0-0'
+ run: pnpm rebuild @sentry/node-cpu-profiler @sentry/node-native-stacktrace
- name: Run integration tests
working-directory: dev-packages/node-integration-tests
@@ -809,9 +826,7 @@ jobs:
name: Cloudflare Integration Tests (${{ matrix.shard }}/2)
needs: [job_get_metadata, job_build]
if: needs.job_build.outputs.changed_cloudflare_integration == 'true' || github.event_name != 'pull_request'
- runs-on:
- namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request'
- && '-pr' || '' }}
+ runs-on: ubuntu-24.04
timeout-minutes: 15
strategy:
fail-fast: false
@@ -828,11 +843,12 @@ jobs:
node-version-file: 'package.json'
- name: Restore caches
uses: ./.github/actions/restore-cache
+ with:
+ dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }}
# The Vite plugin depends on wrangler and writes config for it, so both move together. In the hoisted layout,
# pnpm keeps the nested `node_modules` folders of the old versions (for example an old workerd under
- # `node_modules/wrangler`), so the install starts from empty `node_modules` folders. They are mount points of the
- # cache volume, so only their contents are deleted.
+ # `node_modules/wrangler`), so the install starts from empty `node_modules` folders.
- name: Install the latest wrangler
if: matrix.wrangler == 'latest'
run: |
@@ -852,9 +868,7 @@ jobs:
if:
needs.job_get_metadata.outputs.is_release != 'true' && (needs.job_build.outputs.changed_cloudflare_integration ==
'true' || github.event_name != 'pull_request')
- runs-on:
- namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript-latest-wrangler${{ github.event_name ==
- 'pull_request' && '-pr' || '' }}
+ runs-on: ubuntu-24.04
timeout-minutes: 15
strategy:
fail-fast: false
@@ -867,14 +881,14 @@ jobs:
name: Bundler Plugin Integration Tests (Node ${{ matrix.node }})
needs: [job_get_metadata, job_build]
if: needs.job_build.outputs.changed_bundler_plugin_integration == 'true' || github.event_name != 'pull_request'
- runs-on:
- namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name ==
- 'pull_request' && '-pr' || '' }}
+ runs-on: ubuntu-24.04
timeout-minutes: 15
+ # Node 27 is an alpha: its ABI and APIs can change, so its failures must not fail the required checks.
+ continue-on-error: ${{ matrix.node == '^27.0.0-0' }}
strategy:
fail-fast: false
matrix:
- node: [20.19, 22, 24, 26]
+ node: [20.19, 22, 24, 26, '^27.0.0-0']
steps:
- name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }})
uses: actions/checkout@v7
@@ -886,6 +900,8 @@ jobs:
node-version: ${{ matrix.node }}
- name: Restore caches
uses: ./.github/actions/restore-cache
+ with:
+ dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }}
- name: Run integration tests
working-directory: dev-packages/bundler-plugin-integration-tests
@@ -895,9 +911,7 @@ jobs:
name: Bun (${{ matrix.bun }}) Integration Tests (${{ matrix.shard }}/2)
needs: [job_get_metadata, job_build]
if: needs.job_build.outputs.changed_bun_integration == 'true' || github.event_name != 'pull_request'
- runs-on:
- namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name ==
- 'pull_request' && '-pr' || '' }}
+ runs-on: ubuntu-24.04
timeout-minutes: 15
strategy:
fail-fast: false
@@ -920,6 +934,8 @@ jobs:
bun-version: ${{ matrix.bun }}
- name: Restore caches
uses: ./.github/actions/restore-cache
+ with:
+ dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }}
- name: Run integration tests
working-directory: dev-packages/bun-integration-tests
@@ -929,9 +945,7 @@ jobs:
name: Deno Integration Tests (${{ matrix.shard }}/2)
needs: [job_get_metadata, job_build]
if: needs.job_build.outputs.changed_deno_integration == 'true' || github.event_name != 'pull_request'
- runs-on:
- namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name ==
- 'pull_request' && '-pr' || '' }}
+ runs-on: ubuntu-24.04
timeout-minutes: 15
strategy:
fail-fast: false
@@ -952,6 +966,8 @@ jobs:
deno-version: 'v2.8.3'
- name: Restore caches
uses: ./.github/actions/restore-cache
+ with:
+ dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }}
- name: Build @sentry/deno
working-directory: packages/deno
run: pnpm run build
@@ -974,9 +990,7 @@ jobs:
always() &&
needs.job_build.result == 'success'
needs: [job_get_metadata, job_build]
- runs-on:
- namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name ==
- 'pull_request' && '-pr' || '' }}
+ runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }})
@@ -997,9 +1011,13 @@ jobs:
- name: Restore caches
uses: ./.github/actions/restore-cache
+ with:
+ dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }}
- name: Build tarballs
- run: pnpm run build:tarball
+ run: |
+ pnpm run clean:tarballs
+ pnpm run build:tarballs --parallel=8
- name: Upload tarball artifacts
uses: actions/upload-artifact@v7
@@ -1021,20 +1039,20 @@ jobs:
compression-level: 6
overwrite: true
- job_e2e_tests:
+ # GitHub Actions allows at most 256 jobs in one matrix, so the E2E tests are split into two jobs that share `env`
+ # and `steps`.
+ job_e2e_tests_1:
name: E2E ${{ matrix.label || matrix.test-application }} Test
# We need to add the `always()` check here because the previous step has this as well :(
# See: https://github.com/actions/runner/issues/2205
if:
- always() && needs.job_build_tarballs.result == 'success' && needs.job_build.outputs.e2e-matrix !='{"include":[]}'
+ always() && needs.job_build_tarballs.result == 'success' && needs.job_build.outputs.e2e-matrix-1 !=
+ '{"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:
- ${{ matrix.runner || 'namespace-profile-sentry-javascript-small' }};overrides.cache-tag=sentry-javascript-e2e${{
- github.event_name == 'pull_request' && '-pr' || '' }}
+ runs-on: ubuntu-24.04
timeout-minutes: 15
- env:
- # We just use a dummy DSN here, only send to the tunnel anyhow
+ # We just use a dummy DSN here, only send to the tunnel anyhow
+ env: &e2e-env
E2E_TEST_DSN: 'https://username@domain/123'
# Needed because some apps expect a certain prefix
NEXT_PUBLIC_E2E_TEST_DSN: 'https://username@domain/123'
@@ -1048,8 +1066,8 @@ jobs:
pnpm_config_verify_deps_before_run: 'false'
strategy:
fail-fast: false
- matrix: ${{ fromJson(needs.job_build.outputs.e2e-matrix) }}
- steps:
+ matrix: ${{ fromJson(needs.job_build.outputs.e2e-matrix-1) }}
+ steps: &e2e-steps
- name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }})
uses: actions/checkout@v7
with:
@@ -1061,10 +1079,9 @@ jobs:
runtime: ${{ matrix.runtime }}
deno-version: ${{ matrix.deno-version }}
- # 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.
+ # Installs Playwright while the app builds. Keep it right before the build: the runner sets up a background
+ # composite action's steps from the job's shared `github` context, which fails with "Collection was modified" if
+ # a foreground step starts at that moment. Starting next to `download-artifact` also made it fail to read the event file.
- name: Install Playwright
id: install-playwright
background: true
@@ -1073,23 +1090,6 @@ 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
@@ -1115,6 +1115,20 @@ jobs:
overwrite: true
retention-days: 7
+ job_e2e_tests_2:
+ name: E2E ${{ matrix.label || matrix.test-application }} Test
+ if:
+ always() && needs.job_build_tarballs.result == 'success' && needs.job_build.outputs.e2e-matrix-2 !=
+ '{"include":[]}'
+ needs: [job_get_metadata, job_build, job_build_layer, job_build_tarballs]
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+ env: *e2e-env
+ strategy:
+ fail-fast: false
+ matrix: ${{ fromJson(needs.job_build.outputs.e2e-matrix-2) }}
+ steps: *e2e-steps
+
# - 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. optional-e2e.yml reports the results in a PR comment.
@@ -1128,8 +1142,7 @@ jobs:
needs.job_build.outputs.e2e-matrix-optional != '{"include":[]}' && (github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository) && github.actor != 'dependabot[bot]'
needs: [job_get_metadata, job_build, job_build_tarballs]
- # This job only waits for optional-e2e.yml, which can take most of an hour. GitHub runners are free for public
- # repos and Namespace runners are paid per minute, so the wait runs here and the tests run on Namespace.
+ # This job only waits for optional-e2e.yml, which can take most of an hour.
runs-on: ubuntu-24.04
timeout-minutes: 60
permissions:
@@ -1146,7 +1159,7 @@ jobs:
node-version-file: 'package.json'
- name: Install pnpm
uses: ./.github/actions/install-pnpm
- # The Namespace cache volume is not available here, so only install what the action needs
+ # Only install what the action needs
- name: Install action dependencies
run: pnpm install --frozen-lockfile --filter @sentry-internal/optional-e2e-gh-action
- name: Run optional E2E tests
@@ -1175,7 +1188,8 @@ jobs:
job_deno_integration_tests,
job_browser_playwright_tests,
job_browser_loader_tests,
- job_e2e_tests,
+ job_e2e_tests_1,
+ job_e2e_tests_2,
job_artifacts,
job_lint,
job_check_lockfile,
@@ -1184,7 +1198,7 @@ jobs:
]
# Always run this, even if a dependent job failed
if: always()
- runs-on: namespace-profile-sentry-javascript-small
+ runs-on: ubuntu-24.04
permissions: {}
steps:
- name: Check for failures
diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml
index 55a6d1f10bef..422cecffa19c 100644
--- a/.github/workflows/canary.yml
+++ b/.github/workflows/canary.yml
@@ -12,15 +12,6 @@ on:
env:
HEAD_COMMIT: ${{ github.event.inputs.commit || github.sha }}
- CACHED_BUILD_PATHS: |
- ${{ github.workspace }}/packages/*/*.tgz
- ${{ github.workspace }}/dev-packages/test-utils/*.tgz
- ${{ github.workspace }}/node_modules
- ${{ github.workspace }}/packages/*/node_modules
- ${{ github.workspace }}/dev-packages/*/node_modules
- ${{ github.workspace }}/dev-packages/*/build
- ${{ github.workspace }}/packages/*/build
-
permissions:
contents: read
issues: write
@@ -28,8 +19,10 @@ permissions:
jobs:
job_e2e_prepare:
name: Prepare E2E Canary tests
- runs-on: namespace-profile-sentry-javascript-large
+ runs-on: ubuntu-24.04
timeout-minutes: 30
+ outputs:
+ matrix: ${{ steps.matrix.outputs.matrix }}
steps:
- name: Check out current commit
uses: actions/checkout@v7
@@ -41,11 +34,6 @@ jobs:
uses: actions/setup-node@v7
with:
node-version-file: 'package.json'
- - name: Check canary cache
- uses: actions/cache@v6.1.0
- with:
- path: ${{ env.CACHED_BUILD_PATHS }}
- key: canary-${{ env.HEAD_COMMIT }}
- name: Install dependencies
run: pnpm install
- name: Build packages
@@ -54,10 +42,37 @@ jobs:
- name: Build tarballs
run: pnpm run build:tarball
+ # Same artifacts as in build.yml, so the test jobs can use the shared prepare-e2e-test action
+ - name: Upload tarball artifacts
+ uses: actions/upload-artifact@v7
+ with:
+ name: build-tarball-output
+ path: packages/*/*.tgz
+ if-no-files-found: error
+ retention-days: 4
+ compression-level: 6
+ overwrite: true
+
+ - name: Upload test-utils tarball artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: build-test-utils-tarball-output
+ path: dev-packages/test-utils/*.tgz
+ if-no-files-found: error
+ retention-days: 4
+ compression-level: 6
+ overwrite: true
+
+ - name: Determine canary test applications
+ id: matrix
+ run: pnpm --silent run ci:build-matrix-canary >> $GITHUB_OUTPUT
+ working-directory: dev-packages/e2e-tests
+
job_e2e_tests:
- name: E2E ${{ matrix.label }} Test
+ name: E2E ${{ matrix.label || matrix.test-application }} Test
needs: [job_e2e_prepare]
- runs-on: namespace-profile-sentry-javascript
+ if: needs.job_e2e_prepare.outputs.matrix != '{"include":[]}'
+ runs-on: ubuntu-24.04
timeout-minutes: 20
env:
# We just use a dummy DSN here, only send to the tunnel anyhow
@@ -68,105 +83,30 @@ jobs:
REACT_APP_E2E_TEST_DSN: 'https://username@domain/123'
E2E_TEST_SENTRY_ORG_SLUG: 'sentry-javascript-sdks'
E2E_TEST_SENTRY_PROJECT: 'sentry-javascript-e2e-tests'
+ # The `runtime` of a runtime variant, see dev-packages/e2e-tests/README.md
+ RUNTIME: ${{ matrix.runtime }}
+ # Without the root node_modules, `pnpm run` would first install the whole workspace.
+ pnpm_config_verify_deps_before_run: 'false'
strategy:
fail-fast: false
- matrix:
- include:
- - test-application: 'angular-22'
- build-command: 'test:build-canary'
- label: 'angular-22 (next)'
- - test-application: 'create-react-app'
- build-command: 'test:build-canary'
- label: 'create-react-app (canary)'
- - test-application: 'nextjs-app-dir'
- build-command: 'test:build-15'
- label: 'nextjs-app-dir (next@15)'
- - test-application: 'nextjs-14'
- build-command: 'test:build-canary'
- label: 'nextjs-14 (canary)'
- - test-application: 'nextjs-14'
- build-command: 'test:build-latest'
- label: 'nextjs-14 (latest)'
- - test-application: 'nextjs-15'
- build-command: 'test:build-latest'
- label: 'nextjs-15 (latest)'
- - test-application: 'nextjs-16'
- build-command: 'test:build-canary'
- label: 'nextjs-16 (canary)'
- - test-application: 'nextjs-16'
- build-command: 'test:build-canary-webpack'
- label: 'nextjs-16 (canary-webpack)'
- - test-application: 'react-create-hash-router'
- build-command: 'test:build-canary'
- label: 'react-create-hash-router (canary)'
- - test-application: 'react-router-6'
- build-command: 'test:build-canary'
- label: 'react-router-6 (canary)'
- - test-application: 'nuxt-3'
- build-command: 'test:build-canary'
- label: 'nuxt-3 (canary)'
- - test-application: 'nuxt-4'
- build-command: 'test:build-canary'
- label: 'nuxt-4 (canary)'
- - test-application: 'tanstackstart-react'
- build-command: 'test:build-latest'
- label: 'tanstackstart-react (latest)'
- - test-application: 'nestjs-12'
- build-command: 'test:build-latest'
- label: 'nestjs-12 (latest)'
+ matrix: ${{ fromJson(needs.job_e2e_prepare.outputs.matrix) }}
steps:
- name: Check out current commit
uses: actions/checkout@v7
with:
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: 'package.json'
-
- - name: Restore canary cache
- uses: actions/cache/restore@v6.1.0
- with:
- path: ${{ env.CACHED_BUILD_PATHS }}
- key: canary-${{ env.HEAD_COMMIT }}
-
- - name: Prepare e2e tests
- run: pnpm run test:prepare
- working-directory: dev-packages/e2e-tests
-
- - name: Validate test setup
- run: pnpm run test:validate
- working-directory: dev-packages/e2e-tests
-
- - name: Copy to temp
- run:
- pnpm run ci:copy-to-temp ./test-applications/${{ matrix.test-application }} ${{ runner.temp
- }}/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
+ - name: Prepare E2E test application
+ uses: ./.github/actions/prepare-e2e-test
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
- }}/dev-packages/e2e-tests/packed
- working-directory: dev-packages/e2e-tests
+ test-application: ${{ matrix.test-application }}
+ runtime: ${{ matrix.runtime }}
+ deno-version: ${{ matrix.deno-version }}
- name: Build E2E app
working-directory: ${{ runner.temp }}/test-application
timeout-minutes: 7
- run: pnpm ${{ matrix.build-command }}
+ run: ${{ matrix.build-command || 'pnpm test:build' }}
- name: Install Playwright
uses: ./.github/actions/install-playwright
@@ -177,7 +117,7 @@ jobs:
- name: Run E2E test
working-directory: ${{ runner.temp }}/test-application
timeout-minutes: 15
- run: pnpm test:assert
+ run: ${{ matrix.assert-command || 'pnpm test:assert' }}
- name: Create Issue
if: failure() && github.event_name == 'schedule'
@@ -185,7 +125,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RUN_LINK: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- TITLE: ${{ matrix.label }} Test Failed
+ TITLE: ${{ matrix.label || matrix.test-application }} Test Failed
with:
filename: .github/CANARY_FAILURE_TEMPLATE.md
update_existing: true
diff --git a/.github/workflows/ci-metadata.yml b/.github/workflows/ci-metadata.yml
index ed114f394a19..3b5969cc1803 100644
--- a/.github/workflows/ci-metadata.yml
+++ b/.github/workflows/ci-metadata.yml
@@ -11,7 +11,7 @@ on:
description: 'Short SHA + commit message for display'
value: ${{ jobs.get_metadata.outputs.commit_label }}
is_base_branch:
- description: 'Whether the ref is develop, v9, or v8'
+ description: 'Whether the ref is develop, v10, v9, or v8'
value: ${{ jobs.get_metadata.outputs.is_base_branch }}
is_release:
description: 'Whether the ref is a release branch'
@@ -38,7 +38,7 @@ on:
jobs:
get_metadata:
name: Get Metadata
- runs-on: namespace-profile-sentry-javascript-small
+ runs-on: ubuntu-24.04
permissions:
pull-requests: read
steps:
@@ -81,7 +81,8 @@ jobs:
commit_label: '${{ env.COMMIT_SHA }}: ${{ env.COMMIT_MESSAGE }}'
# Note: These next three have to be checked as strings ('true'/'false')!
is_base_branch:
- ${{ github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/v9' || github.ref == 'refs/heads/v8'}}
+ ${{ github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/v10' || github.ref == 'refs/heads/v9' ||
+ github.ref == 'refs/heads/v8'}}
is_release: ${{ startsWith(github.ref, 'refs/heads/release/') }}
changed_ci: ${{ steps.changed.outputs.workflow == 'true' }}
changed_any_code: ${{ steps.changed.outputs.any_code == 'true' }}
diff --git a/.github/workflows/cleanup-e2e-workers.yml b/.github/workflows/cleanup-e2e-workers.yml
index 917de52e997d..69aae225e408 100644
--- a/.github/workflows/cleanup-e2e-workers.yml
+++ b/.github/workflows/cleanup-e2e-workers.yml
@@ -8,7 +8,7 @@ jobs:
cleanup:
# The optional E2E job deploys only for PRs from this repository, so forks never have a worker to delete.
if: github.event.pull_request.head.repo.full_name == github.repository
- runs-on: namespace-profile-sentry-javascript-small
+ runs-on: ubuntu-latest
permissions: {}
timeout-minutes: 5
env:
diff --git a/.github/workflows/cleanup-pr-caches.yml b/.github/workflows/cleanup-pr-caches.yml
index 4b7dbb4ee476..e4543e524c91 100644
--- a/.github/workflows/cleanup-pr-caches.yml
+++ b/.github/workflows/cleanup-pr-caches.yml
@@ -6,7 +6,7 @@ on:
jobs:
cleanup:
- runs-on: namespace-profile-sentry-javascript-small
+ runs-on: ubuntu-latest
permissions:
# `actions:write` permission is required to delete caches
# See also: https://docs.github.com/en/rest/actions/cache?apiVersion=2022-11-28#delete-a-github-actions-cache-for-a-repository-using-a-cache-id
diff --git a/.github/workflows/clear-cache.yml b/.github/workflows/clear-cache.yml
index 28dba37b8d7c..750b39305d1f 100644
--- a/.github/workflows/clear-cache.yml
+++ b/.github/workflows/clear-cache.yml
@@ -21,7 +21,7 @@ on:
jobs:
clear-caches:
name: Delete all caches
- runs-on: namespace-profile-sentry-javascript
+ runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 79b9bb42a677..4e41c7f5d145 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -32,7 +32,7 @@ concurrency:
jobs:
analyze:
name: Analyze
- runs-on: namespace-profile-sentry-javascript-large
+ runs-on: ubuntu-latest
# Skip for pushes from dependabot, which is not supported
if: github.event_name == 'pull_request' || github.actor != 'dependabot[bot]'
diff --git a/.github/workflows/dependabot-auto-triage.yml b/.github/workflows/dependabot-auto-triage.yml
index 56ebdfe42f2f..d20178d76b9a 100644
--- a/.github/workflows/dependabot-auto-triage.yml
+++ b/.github/workflows/dependabot-auto-triage.yml
@@ -35,6 +35,11 @@ env:
FIX_CAP_DEV: '3'
# ⬇️ debatable but let's start off with this
PR_REVIEWERS: 'getsentry/team-javascript-sdks-framework'
+ CACHED_DEPENDENCY_PATHS: |
+ ${{ github.workspace }}/node_modules
+ ${{ github.workspace }}/packages/*/node_modules
+ ${{ github.workspace }}/dev-packages/*/node_modules
+ ~/.cache/mongodb-binaries/
concurrency:
group: dependabot-auto-triage
@@ -43,7 +48,7 @@ concurrency:
jobs:
classify:
name: Classify alerts & auto-dismiss noise
- runs-on: namespace-profile-sentry-javascript
+ runs-on: ubuntu-24.04
timeout-minutes: 20
outputs:
runtime_alerts: ${{ steps.select.outputs.runtime_alerts }}
@@ -124,7 +129,7 @@ jobs:
if:
(github.event_name == 'schedule' || github.event.inputs.mode == 'full') && needs.classify.outputs.runtime_alerts
!= ''
- runs-on: namespace-profile-sentry-javascript
+ runs-on: ubuntu-24.04
timeout-minutes: 40
environment: ci-triage
steps:
@@ -155,7 +160,7 @@ jobs:
git config user.email "${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com"
- name: Open batched runtime fix PR via skill
- uses: anthropics/claude-code-action@8ce9314fa9a404564fa7e954cd84f25bcba2b829 # v1
+ uses: anthropics/claude-code-action@d535f5172dec02b204866b393d6b20b9b2aaf98d # v1
env:
ANTHROPIC_BASE_URL: https://openrouter.ai/api
with:
@@ -213,7 +218,7 @@ jobs:
needs: classify
if:
(github.event_name == 'schedule' || github.event.inputs.mode == 'full') && needs.classify.outputs.dev_alerts != ''
- runs-on: namespace-profile-sentry-javascript
+ runs-on: ubuntu-24.04
timeout-minutes: 40
environment: ci-triage
steps:
@@ -244,7 +249,7 @@ jobs:
git config user.email "${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com"
- name: Open batched dev fix PR via skill
- uses: anthropics/claude-code-action@8ce9314fa9a404564fa7e954cd84f25bcba2b829 # v1
+ uses: anthropics/claude-code-action@d535f5172dec02b204866b393d6b20b9b2aaf98d # v1
env:
ANTHROPIC_BASE_URL: https://openrouter.ai/api
with:
diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml
index 997cfc71ff30..8d09e421768f 100644
--- a/.github/workflows/enforce-license-compliance.yml
+++ b/.github/workflows/enforce-license-compliance.yml
@@ -27,7 +27,7 @@ concurrency:
jobs:
enforce-license-compliance:
- runs-on: namespace-profile-sentry-javascript
+ runs-on: ubuntu-24.04
steps:
- name: 'Enforce License Compliance'
uses: getsentry/action-enforce-license-compliance@main
diff --git a/.github/workflows/external-contributors.yml b/.github/workflows/external-contributors.yml
index 6e3a3c5aff8c..1b131781dc05 100644
--- a/.github/workflows/external-contributors.yml
+++ b/.github/workflows/external-contributors.yml
@@ -12,7 +12,7 @@ jobs:
permissions:
pull-requests: write
contents: write
- runs-on: namespace-profile-sentry-javascript
+ runs-on: ubuntu-24.04
if: |
github.event.pull_request.merged == true
&& github.event.pull_request.author_association != 'COLLABORATOR'
diff --git a/.github/workflows/gitflow-merge-conflict.yml b/.github/workflows/gitflow-merge-conflict.yml
index 6195a73350d5..102d05b6d03b 100644
--- a/.github/workflows/gitflow-merge-conflict.yml
+++ b/.github/workflows/gitflow-merge-conflict.yml
@@ -9,7 +9,7 @@ on:
jobs:
check-merge-conflicts:
name: Detect merge conflicts in gitflow PRs
- runs-on: namespace-profile-sentry-javascript-small
+ runs-on: ubuntu-24.04
if: |
${{ contains(github.event.pull_request.labels.*.name, 'Dev: Gitflow') }}
permissions:
diff --git a/.github/workflows/gitflow-sync-develop.yml b/.github/workflows/gitflow-sync-develop.yml
index 21d3c45dc1d0..20f8b335fd0f 100644
--- a/.github/workflows/gitflow-sync-develop.yml
+++ b/.github/workflows/gitflow-sync-develop.yml
@@ -17,7 +17,7 @@ env:
jobs:
main:
name: Create PR master->develop
- runs-on: namespace-profile-sentry-javascript-small
+ runs-on: ubuntu-24.04
permissions:
pull-requests: write
contents: write
diff --git a/.github/workflows/issue-package-label.yml b/.github/workflows/issue-package-label.yml
index c855066f318f..8ac1c7987801 100644
--- a/.github/workflows/issue-package-label.yml
+++ b/.github/workflows/issue-package-label.yml
@@ -7,7 +7,7 @@ on:
jobs:
add_labels:
name: Add package label
- runs-on: namespace-profile-sentry-javascript-small
+ runs-on: ubuntu-latest
if: ${{ !github.event.issue.pull_request }}
steps:
- name: Get used package from issue body
diff --git a/.github/workflows/label-external-prs.yml b/.github/workflows/label-external-prs.yml
index 6caf6fae7387..e6a73aaae5a7 100644
--- a/.github/workflows/label-external-prs.yml
+++ b/.github/workflows/label-external-prs.yml
@@ -9,7 +9,7 @@ jobs:
name: Add external label
permissions:
pull-requests: write
- runs-on: namespace-profile-sentry-javascript-small
+ runs-on: ubuntu-24.04
if: |
github.event.pull_request.author_association != 'COLLABORATOR'
&& github.event.pull_request.author_association != 'MEMBER'
diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml
deleted file mode 100644
index 1073b0c17b51..000000000000
--- a/.github/workflows/lighthouse.yml
+++ /dev/null
@@ -1,32 +0,0 @@
-name: 'Nightly: Lighthouse'
-
-# Nightly: builds the Lighthouse fixture app and uploads bundles to the
-# lighthouse.sentry.gg lab. Never blocks merges.
-
-on:
- schedule:
- - cron: '0 0 * * *'
- workflow_dispatch:
-
-jobs:
- build-and-upload:
- name: Bundle and upload Lighthouse cells
- runs-on: namespace-profile-sentry-javascript-large
- timeout-minutes: 30
- env:
- LIGHTHOUSE_LAB_URL: ${{ secrets.LIGHTHOUSE_LAB_URL }}
- LIGHTHOUSE_UPLOAD_TOKEN: ${{ secrets.LIGHTHOUSE_UPLOAD_TOKEN }}
- VITE_E2E_TEST_DSN: 'https://username@domain/123'
- steps:
- - uses: actions/checkout@v7
- - name: Install pnpm
- uses: ./.github/actions/install-pnpm
- - uses: actions/setup-node@v7
- with:
- node-version-file: 'package.json'
- - run: pnpm install --frozen-lockfile
- - run: pnpm run build:ci
- - run: pnpm run build:tarball
- - run: pnpm run test:prepare
- working-directory: dev-packages/e2e-tests
- - run: node scripts/lighthouse-bundle-and-upload.mjs
diff --git a/.github/workflows/optional-e2e.yml b/.github/workflows/optional-e2e.yml
index f73eaa256fc0..0fc020c9b327 100644
--- a/.github/workflows/optional-e2e.yml
+++ b/.github/workflows/optional-e2e.yml
@@ -37,11 +37,7 @@ env:
jobs:
job_optional_e2e_tests:
name: E2E ${{ matrix.label || matrix.test-application }} Test (optional)
- # 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:
- namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript-e2e${{ inputs.pr_number != '' && '-pr'
- || '' }}
+ runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
@@ -59,8 +55,7 @@ jobs:
E2E_OPENROUTER_API_KEY: ${{ secrets.E2E_OPENROUTER_API_KEY }}
# Only this app talks to OpenAI directly (OpenRouter has no Decisions route), so only it gets the key.
E2E_OPENAI_DECISIONS_API_KEY:
- ${{ matrix.test-application == 'node-openai-decisions-send-to-sentry' && secrets.E2E_OPENAI_DECISIONS_API_KEY ||
- '' }}
+ ${{ matrix.test-application == 'node-openai-decisions' && secrets.E2E_OPENAI_DECISIONS_API_KEY || '' }}
# Used by test apps that deploy a real Cloudflare Worker, e.g. cloudflare-workers-send-to-sentry
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
diff --git a/.github/workflows/pr-stale-notifier.yml b/.github/workflows/pr-stale-notifier.yml
index 3c4816dafebe..13bba260a0b0 100644
--- a/.github/workflows/pr-stale-notifier.yml
+++ b/.github/workflows/pr-stale-notifier.yml
@@ -15,7 +15,7 @@ concurrency:
jobs:
stale:
- runs-on: namespace-profile-sentry-javascript-small
+ runs-on: ubuntu-latest
steps:
- name: Warn and close stale PRs
uses: actions/stale@v11
diff --git a/.github/workflows/release-comment-issues.yml b/.github/workflows/release-comment-issues.yml
index 88055af892c8..dfb782b1b6d8 100644
--- a/.github/workflows/release-comment-issues.yml
+++ b/.github/workflows/release-comment-issues.yml
@@ -12,7 +12,7 @@ on:
# This workflow is triggered when a release is published
jobs:
release-comment-issues:
- runs-on: namespace-profile-sentry-javascript-small
+ runs-on: ubuntu-24.04
name: 'Notify issues'
steps:
- name: Get version
diff --git a/.github/workflows/release-size-info.yml b/.github/workflows/release-size-info.yml
index 591a14b6b553..a1f75303d1ff 100644
--- a/.github/workflows/release-size-info.yml
+++ b/.github/workflows/release-size-info.yml
@@ -13,7 +13,7 @@ on:
# It fetches the size-limit info from the release branch and adds it to the release
jobs:
release-size-info:
- runs-on: namespace-profile-sentry-javascript-small
+ runs-on: ubuntu-24.04
name: 'Add size-limit info to release'
steps:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e579c239dcfe..0d9d491c87a4 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -18,7 +18,7 @@ permissions:
jobs:
release:
- runs-on: namespace-profile-sentry-javascript-small
+ runs-on: ubuntu-24.04
name: 'Release a new version'
steps:
- name: Get auth token
@@ -39,7 +39,7 @@ jobs:
with:
node-version-file: 'package.json'
- name: Prepare release
- uses: getsentry/craft@25028d0646040cc0a669ae3314cbf884f4c4347a # v2.31.2
+ uses: getsentry/craft@3f1f02d50b08b8c509a28d2cc2abf594e9a28011 # v3.5.0
env:
GITHUB_TOKEN: ${{ steps.token.outputs.token }}
with:
diff --git a/.github/workflows/size-check.yml b/.github/workflows/size-check.yml
index ababb321b866..3321585750bb 100644
--- a/.github/workflows/size-check.yml
+++ b/.github/workflows/size-check.yml
@@ -13,10 +13,17 @@ concurrency:
group: size-check-label-${{ github.event.pull_request.number }}-${{ github.event.label.name }}
cancel-in-progress: true
+env:
+ CACHED_DEPENDENCY_PATHS: |
+ ${{ github.workspace }}/node_modules
+ ${{ github.workspace }}/packages/*/node_modules
+ ${{ github.workspace }}/dev-packages/*/node_modules
+ ~/.cache/mongodb-binaries/
+
jobs:
rerun:
if: github.event.label.name == 'Accept Bundlesize Increase'
- runs-on: namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript
+ runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- name: Check out workflow commit
diff --git a/.github/workflows/track-framework-updates.yml b/.github/workflows/track-framework-updates.yml
index 3e0172884e7f..cebb7ef821dc 100644
--- a/.github/workflows/track-framework-updates.yml
+++ b/.github/workflows/track-framework-updates.yml
@@ -18,7 +18,7 @@ concurrency:
jobs:
track-updates:
- runs-on: namespace-profile-sentry-javascript-small
+ runs-on: ubuntu-latest
environment: ci-triage
permissions:
contents: read
@@ -43,7 +43,7 @@ jobs:
- name: Run Claude digest
id: digest
- uses: anthropics/claude-code-action@8ce9314fa9a404564fa7e954cd84f25bcba2b829 # v1
+ uses: anthropics/claude-code-action@d535f5172dec02b204866b393d6b20b9b2aaf98d # v1
env:
ANTHROPIC_BASE_URL: https://openrouter.ai/api
with:
diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml
index f3c9f955547f..251134290f00 100644
--- a/.github/workflows/validate-pr.yml
+++ b/.github/workflows/validate-pr.yml
@@ -6,11 +6,11 @@ on:
jobs:
validate-pr:
- runs-on: namespace-profile-sentry-javascript-small
+ runs-on: ubuntu-24.04
permissions:
pull-requests: write
steps:
- - uses: getsentry/github-workflows/validate-pr@36c729264d2edc29ebae61950c50e1e9f043ad7e
+ - uses: getsentry/github-workflows/validate-pr@01bcb7fe69bb48bacd83e675840e3dc95b33c730
with:
app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }}
private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }}
diff --git a/.github/workflows/weekly-flaky-tests.yml b/.github/workflows/weekly-flaky-tests.yml
index 23d0f340b174..b9c02c1b0421 100644
--- a/.github/workflows/weekly-flaky-tests.yml
+++ b/.github/workflows/weekly-flaky-tests.yml
@@ -18,7 +18,7 @@ concurrency:
jobs:
report:
name: Weekly flaky test report
- runs-on: namespace-profile-sentry-javascript-small
+ runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
diff --git a/.oxlintrc.base.json b/.oxlintrc.base.json
index 41be2844716d..3c42d69b4bb4 100644
--- a/.oxlintrc.base.json
+++ b/.oxlintrc.base.json
@@ -61,10 +61,14 @@
"typescript/no-duplicate-type-constituents": "off",
// @types/node 20 still marks `channel.subscribe()` and `channel.unsubscribe()` as deprecated,
// but Node revoked this deprecation (DEP0163) in v22.20.0 and v24.8.0.
+ // `handleTunnelRequest` has only one deprecated overload, but the rule flags every re-export of it.
"typescript/no-deprecated": [
"error",
{
- "allow": [{ "from": "package", "package": "diagnostics_channel", "name": ["subscribe", "unsubscribe"] }]
+ "allow": [
+ { "from": "package", "package": "diagnostics_channel", "name": ["subscribe", "unsubscribe"] },
+ "handleTunnelRequest"
+ ]
}
]
},
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1a0d0150cd82..bf3624e80c8a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,208 @@
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
+## 11.11.0
+
+### Important Changes
+
+- **ref(cloudflare): Use the shared request handler wrapper ([#25588](https://github.com/getsentry/sentry-javascript/pull/25588))**
+
+ Cloudflare Workers now use the same request handler wrapper as `Deno.serve` and `Bun.serve`. Changes for Cloudflare users:
+
+ - Request spans now set `client.address` from the `CF-Connecting-IP` header (or another forwarding header), as on the other runtimes. This follows `dataCollection.userInfo`, which is `true` by default. To leave it out, set `dataCollection: { userInfo: false }`.
+ - Request spans now have the response header attributes, and events have the `response` context with the status code.
+ - When the source of a streamed response fails, the SDK now captures the error and marks the span as failed. With `captureErrors: false`, it does not capture it.
+ - Request spans now list every `Set-Cookie` header of a response, as on Deno and Bun.
+ - Without `AsyncLocalStorage` (for example on Shopify Oxygen), events now also get the request URL and method.
+
+- **feat(nestjs): Add an option to monitor every @Cron method ([#25471](https://github.com/getsentry/sentry-javascript/pull/25471))**
+
+ Set `monitorCronJobs: true` on `nestIntegration()` to send check-ins for every `@Cron()` method, without adding `@SentryCron` to each one. It's off by default. Each monitored job creates a monitor in Sentry, and monitors are billed. Use `excludeCronJobs` to skip jobs by slug. Methods that also have `@SentryCron` check in once, with the slug and options passed to `@SentryCron`.
+
+- **feat(node): Add cronJobIntegration to monitor cron jobs ([#25477](https://github.com/getsentry/sentry-javascript/pull/25477))**
+
+ Add `Sentry.cronJobIntegration()` to `integrations` to send check-ins for every named `CronJob` of the `cron` package (4.3+), with the job name as the monitor slug. It's off by default. Each monitored job creates a monitor in Sentry, and monitors are billed. Use `excludeJobs` to skip jobs by name. `Sentry.cron.instrumentCron` is deprecated; use `cronJobIntegration` instead.
+
+- **fix(server-utils): Instrument @anthropic-ai/sdk 1.x and turn off its own OTel spans ([#25750](https://github.com/getsentry/sentry-javascript/pull/25750))**
+
+ The Anthropic integration now instruments `@anthropic-ai/sdk` 1.x. Since 0.134 the SDK also records its own OpenTelemetry spans, such as `anthropic.messages.create`, which duplicated Sentry's `gen_ai` spans. Once Sentry instruments the SDK, it sets `ANTHROPIC_OPEN_TELEMETRY=false` to turn those spans off. To keep them, set `ANTHROPIC_OPEN_TELEMETRY=true` or pass the `openTelemetry` option to the Anthropic client.
+
+- **feat(nextjs): Mark pageloads of cached pages ([#25318](https://github.com/getsentry/sentry-javascript/pull/25318))**
+
+ When Next.js serves a page from its cache, for example an ISR page or a `cacheComponents` shell, the pageload now has the `page.server_rendering.type` attribute. It is `static` if the whole page came from the cache, and `partially_static` if parts of it rendered for this request. Then the pageload stays connected to this request, as before. If the request that rendered the cached page was sampled, the pageload also links to it. So in Sentry, a cached page no longer looks like missing server instrumentation.
+
+### Other Changes
+
+- feat(astro): Add `@sentry/astro/integration` entry for the Astro integration ([#25726](https://github.com/getsentry/sentry-javascript/pull/25726))
+- feat(bundler-plugins): Add `sourcemaps.deleteSourcemapsAfterUpload` and staged uploads ([#25637](https://github.com/getsentry/sentry-javascript/pull/25637))
+- feat(nestjs): Derive SentryCron monitor config from @Cron ([#25013](https://github.com/getsentry/sentry-javascript/pull/25013))
+- feat(nextjs): Add `applicationKeyExcludeDependencies` option ([#25818](https://github.com/getsentry/sentry-javascript/pull/25818))
+- feat(node): Add `awsLambdaWebIntegration` for Lambda Web Functions ([#25355](https://github.com/getsentry/sentry-javascript/pull/25355))
+- feat(node): Add BullMQ integration ([#21777](https://github.com/getsentry/sentry-javascript/pull/21777))
+- feat(node): Add nodeCronIntegration to monitor node-cron tasks ([#25473](https://github.com/getsentry/sentry-javascript/pull/25473))
+- feat(node): Add nodeScheduleIntegration to monitor node-schedule jobs ([#25474](https://github.com/getsentry/sentry-javascript/pull/25474))
+- feat(node): Support postgres v4 through its native tracing channels ([#26076](https://github.com/getsentry/sentry-javascript/pull/26076))
+- feat(server-utils): Extract `getSqlSpanName` for SQL span names ([#25929](https://github.com/getsentry/sentry-javascript/pull/25929))
+- feat(server-utils): Move to new `getSqlQueryAttributes` helper for consistency ([#25927](https://github.com/getsentry/sentry-javascript/pull/25927))
+- feat(server-utils): Record AI SDK v7 runtime and tool context on Vercel AI spans ([#25774](https://github.com/getsentry/sentry-javascript/pull/25774))
+- feat(server-utils): Set `db.operation.name` on SQL spans ([#25850](https://github.com/getsentry/sentry-javascript/pull/25850))
+- feat(server-utils): Support @mistralai/mistralai v3 ([#25827](https://github.com/getsentry/sentry-javascript/pull/25827))
+- feat(solidstart): Export `withSentry` from `@sentry/solidstart/vite` ([#25725](https://github.com/getsentry/sentry-javascript/pull/25725))
+- fix(astro): Initialize the server SDK on the first endpoint request ([#25731](https://github.com/getsentry/sentry-javascript/pull/25731))
+- fix(browser): Attribute long animation frame spans to the longest script ([#25990](https://github.com/getsentry/sentry-javascript/pull/25990))
+- fix(bundler-plugins): Inject debug IDs into every esbuild rebuild ([#26099](https://github.com/getsentry/sentry-javascript/pull/26099))
+- fix(bundler-plugins): upload literal artifact paths ([#25356](https://github.com/getsentry/sentry-javascript/pull/25356))
+- fix(cloudflare): Run build-time instrumentation in vite dev ([#25777](https://github.com/getsentry/sentry-javascript/pull/25777))
+- fix(core): Call native toString without page-replaceable lookups in FunctionToString ([#25921](https://github.com/getsentry/sentry-javascript/pull/25921))
+- fix(core): Enforce linked error limits for aggregate and suppressed errors ([#25873](https://github.com/getsentry/sentry-javascript/pull/25873))
+- fix(core): Only create Supabase db spans when there is a parent span ([#25807](https://github.com/getsentry/sentry-javascript/pull/25807))
+- fix(core): preserve span links through beforeSendSpan ([#25566](https://github.com/getsentry/sentry-javascript/pull/25566))
+- fix(core): Respect header collection for session user agents ([#25871](https://github.com/getsentry/sentry-javascript/pull/25871))
+- fix(deps): Bump glob to 14 and drop the direct dependency from react-router ([#26014](https://github.com/getsentry/sentry-javascript/pull/26014))
+- fix(nextjs): Don't abort Cache Components renders on Cloudflare Workers when requests overlap ([#25315](https://github.com/getsentry/sentry-javascript/pull/25315))
+- fix(nextjs): Drop tunnel requests under the Next.request proxy root span ([#26096](https://github.com/getsentry/sentry-javascript/pull/26096))
+- fix(nextjs): Fork isolation scope for Next.request proxy root spans ([#26084](https://github.com/getsentry/sentry-javascript/pull/26084))
+- fix(nextjs): Strip source map comments before trailing debug IDs ([#25881](https://github.com/getsentry/sentry-javascript/pull/25881))
+- fix(nitro): Capture errors thrown in tasks ([#26089](https://github.com/getsentry/sentry-javascript/pull/26089))
+- fix(nuxt): Keep storage instrumentation plugin synchronous ([#25949](https://github.com/getsentry/sentry-javascript/pull/25949))
+- fix(replay): Skip buffered replays for captureMessage stacks from the attachStacktrace default ([#25846](https://github.com/getsentry/sentry-javascript/pull/25846))
+- fix(server-utils): Fix orchestrion source maps of instrumented modules ([#25772](https://github.com/getsentry/sentry-javascript/pull/25772))
+- fix(server-utils): Read the pi-durable session id in every phase outside a run ([#25838](https://github.com/getsentry/sentry-javascript/pull/25838))
+- fix(server-utils): Require a parent span for Firestore operations ([#25855](https://github.com/getsentry/sentry-javascript/pull/25855))
+- fix(server-utils): Require a parent span for Tedious queries ([#25822](https://github.com/getsentry/sentry-javascript/pull/25822))
+- fix(server-utils): Use onlyIfParent for Firestore spans ([#25877](https://github.com/getsentry/sentry-javascript/pull/25877))
+- fix(server-utils): Use onlyIfParent for Tedious spans ([#25876](https://github.com/getsentry/sentry-javascript/pull/25876))
+- fix(solidstart): Serve the managed tunnel under Vite's base on SolidStart 2 ([#25848](https://github.com/getsentry/sentry-javascript/pull/25848))
+- fix(sveltekit): Resolve Handle and ResolveOptions types on SvelteKit 3 ([#26085](https://github.com/getsentry/sentry-javascript/pull/26085))
+- fix(wasm): Skip length prefix in 16-byte build IDs ([#24857](https://github.com/getsentry/sentry-javascript/pull/24857))
+- ref(core): Deprecate the released `handleTunnelRequest` form and reword the `serveTunnelIntegration` docs ([#25844](https://github.com/getsentry/sentry-javascript/pull/25844))
+
+
+ Internal Changes
+
+- chore(ci): Ignore Dependabot bumps for deps that are also peer deps ([#26015](https://github.com/getsentry/sentry-javascript/pull/26015))
+- chore(ci): Remove nightly sentry-lighthouse upload workflow ([#25966](https://github.com/getsentry/sentry-javascript/pull/25966))
+- chore(dependabot): Group related dependency updates ([#26059](https://github.com/getsentry/sentry-javascript/pull/26059))
+- chore(dependabot): Ignore major react-router updates ([#25892](https://github.com/getsentry/sentry-javascript/pull/25892))
+- chore(dependabot): Preserve SDK compatibility baselines ([#26013](https://github.com/getsentry/sentry-javascript/pull/26013))
+- chore(dependabot): Remove obsolete update rules ([#26007](https://github.com/getsentry/sentry-javascript/pull/26007))
+- chore(deps): Align AWS SDK clients to 3.1161.0 ([#26032](https://github.com/getsentry/sentry-javascript/pull/26032))
+- chore(deps): Bump @actions/core and @actions/github in internal GH actions ([#26109](https://github.com/getsentry/sentry-javascript/pull/26109))
+- chore(deps): Bump @nuxt/devtools from 3.4.1 to 3.4.4 ([#26097](https://github.com/getsentry/sentry-javascript/pull/26097))
+- chore(deps): Bump @sentry/conventions to 0.30.0 ([#25837](https://github.com/getsentry/sentry-javascript/pull/25837))
+- chore(deps): Bump undici from 6.24.1 to 6.29.1 ([#26108](https://github.com/getsentry/sentry-javascript/pull/26108))
+- chore(deps): Bump webpack from 5.104.1 to 5.112.2 ([#25910](https://github.com/getsentry/sentry-javascript/pull/25910))
+- chore(deps): Enable pnpm autoDedupe ([#25961](https://github.com/getsentry/sentry-javascript/pull/25961))
+- chore(deps): Group AWS SDK Dependabot updates ([#26030](https://github.com/getsentry/sentry-javascript/pull/26030))
+- chore(deps-dev): Bump @babel/parser from 7.29.9 to 8.0.10 ([#26043](https://github.com/getsentry/sentry-javascript/pull/26043))
+- chore(deps-dev): Bump @babel/plugin-transform-typescript from 7.29.7 to 8.0.6 ([#26012](https://github.com/getsentry/sentry-javascript/pull/26012))
+- chore(deps-dev): Bump @hono/node-server from 2.1.3 to 2.1.5 ([#26054](https://github.com/getsentry/sentry-javascript/pull/26054))
+- chore(deps-dev): Bump @rollup/plugin-terser from 0.4.4 to 1.0.2 ([#25890](https://github.com/getsentry/sentry-javascript/pull/25890))
+- chore(deps-dev): Bump @testing-library/user-event from 14.5.2 to 14.6.8 ([#26071](https://github.com/getsentry/sentry-javascript/pull/26071))
+- chore(deps-dev): Bump fake-indexeddb from 6.2.5 to 6.2.7 ([#26034](https://github.com/getsentry/sentry-javascript/pull/26034))
+- chore(deps-dev): Bump markdown-table from 3.0.3 to 3.0.4 ([#26074](https://github.com/getsentry/sentry-javascript/pull/26074))
+- chore(deps-dev): Bump node-schedule and @types/node-schedule ([#26058](https://github.com/getsentry/sentry-javascript/pull/26058))
+- chore(deps-dev): Bump redux from 4.0.5 to 5.0.1 ([#25959](https://github.com/getsentry/sentry-javascript/pull/25959))
+- chore(deps-dev): Bump the nestjs group with 2 updates ([#26060](https://github.com/getsentry/sentry-javascript/pull/26060))
+- chore(deps-dev): Bump the react-router group across 1 directory with 3 updates ([#25955](https://github.com/getsentry/sentry-javascript/pull/25955))
+- chore(deps-dev): Bump tsx from 4.23.0 to 4.23.15 ([#25958](https://github.com/getsentry/sentry-javascript/pull/25958))
+- chore(deps-dev): Bump wrangler from 4.61.0 to 4.156.0 ([#26009](https://github.com/getsentry/sentry-javascript/pull/26009))
+- chore(dev-packages): Move dependencies to devDependencies ([#26046](https://github.com/getsentry/sentry-javascript/pull/26046))
+- chore(node-integration-tests): Remove unused dependencies ([#25942](https://github.com/getsentry/sentry-javascript/pull/25942))
+- chore: Add Bugbot guidance for cold imports in tests ([#25976](https://github.com/getsentry/sentry-javascript/pull/25976))
+- chore: Add external contributor to CHANGELOG.md ([#25862](https://github.com/getsentry/sentry-javascript/pull/25862))
+- chore: Add external contributor to CHANGELOG.md ([#25870](https://github.com/getsentry/sentry-javascript/pull/25870))
+- chore: Add external contributor to CHANGELOG.md ([#25872](https://github.com/getsentry/sentry-javascript/pull/25872))
+- chore: Add external contributor to CHANGELOG.md ([#26017](https://github.com/getsentry/sentry-javascript/pull/26017))
+- chore: Add external contributor to CHANGELOG.md ([#26062](https://github.com/getsentry/sentry-javascript/pull/26062))
+- chore: Batch npm dependency updates daily ([#26072](https://github.com/getsentry/sentry-javascript/pull/26072))
+- chore: Exclude integration and bundler tests from Dependabot updates ([#26075](https://github.com/getsentry/sentry-javascript/pull/26075))
+- chore: Ignore major Prisma dependency updates ([#26063](https://github.com/getsentry/sentry-javascript/pull/26063))
+- chore: Prune unused regexp dependencies from lockfile ([#25948](https://github.com/getsentry/sentry-javascript/pull/25948))
+- chore: Refresh Smithy deprecation metadata in lockfile ([#25971](https://github.com/getsentry/sentry-javascript/pull/25971))
+- chore: Remove unused eslint-plugin-regexp dependencies ([#25944](https://github.com/getsentry/sentry-javascript/pull/25944))
+- chore: Remove unused test and build dependencies ([#25945](https://github.com/getsentry/sentry-javascript/pull/25945))
+- ci(canary): Build the canary test matrix from `canaryVariants` ([#25866](https://github.com/getsentry/sentry-javascript/pull/25866))
+- ci(deps): Bump anthropics/claude-code-action from 1.0.236 to 1.0.264 ([#25952](https://github.com/getsentry/sentry-javascript/pull/25952))
+- ci(deps): Bump anthropics/claude-code-action from 1.0.264 to 1.0.266 ([#26008](https://github.com/getsentry/sentry-javascript/pull/26008))
+- ci(deps): Bump getsentry/craft from 2.31.2 to 3.4.0 ([#25987](https://github.com/getsentry/sentry-javascript/pull/25987))
+- ci(deps): Bump getsentry/craft from 3.4.0 to 3.5.0 ([#26011](https://github.com/getsentry/sentry-javascript/pull/26011))
+- ci(deps): Bump getsentry/github-workflows/validate-pr from 36c729264d2edc29ebae61950c50e1e9f043ad7e to 01bcb7fe69bb48bacd83e675840e3dc95b33c730 ([#25954](https://github.com/getsentry/sentry-javascript/pull/25954))
+- ci(deps): Group langchain dependabot updates and skip majors ([#26041](https://github.com/getsentry/sentry-javascript/pull/26041))
+- ci(deps): Ignore langchain in dependabot ([#26056](https://github.com/getsentry/sentry-javascript/pull/26056))
+- ci(node): Add optional Node 27 alpha tests ([#25984](https://github.com/getsentry/sentry-javascript/pull/25984))
+- ci: Drop dead matrix reference in Deno unit tests job ([#25852](https://github.com/getsentry/sentry-javascript/pull/25852))
+- ci: Include v10 in base-branch metadata ([#25977](https://github.com/getsentry/sentry-javascript/pull/25977))
+- ci: run CI on GitHub-hosted runners again ([#26057](https://github.com/getsentry/sentry-javascript/pull/26057))
+- ci: Split the E2E test matrix into two jobs ([#25913](https://github.com/getsentry/sentry-javascript/pull/25913))
+- docs(changelog): Add monitorCronJobs to Unreleased ([#25996](https://github.com/getsentry/sentry-javascript/pull/25996))
+- docs(changelog): Restore order of unreleased section in changelog ([#26006](https://github.com/getsentry/sentry-javascript/pull/26006))
+- feat(deps): Bump @aws-sdk/client-lambda from 3.1068.0 to 3.1161.0 ([#26026](https://github.com/getsentry/sentry-javascript/pull/26026))
+- feat(deps): Bump @embroider/addon-shim from 1.10.3 to 1.11.0 ([#26048](https://github.com/getsentry/sentry-javascript/pull/26048))
+- feat(deps): Bump @flue/runtime from 2.0.3 to 2.3.0 ([#26044](https://github.com/getsentry/sentry-javascript/pull/26044))
+- feat(deps): Bump @hapi/content from 6.0.2 to 6.0.3 ([#26021](https://github.com/getsentry/sentry-javascript/pull/26021))
+- feat(deps): Bump @hono/node-server from 2.0.10 to 2.1.3 ([#26053](https://github.com/getsentry/sentry-javascript/pull/26053))
+- feat(deps): Bump @sveltejs/acorn-typescript from 1.0.9 to 1.0.13 ([#26050](https://github.com/getsentry/sentry-javascript/pull/26050))
+- feat(deps): Bump @types/aws-lambda from 8.10.161 to 8.10.164 ([#26066](https://github.com/getsentry/sentry-javascript/pull/26066))
+- feat(deps): Bump axios from 1.20.0 to 1.21.0 ([#26033](https://github.com/getsentry/sentry-javascript/pull/26033))
+- feat(deps): Bump dataloader from 2.2.2 to 2.2.3 ([#25891](https://github.com/getsentry/sentry-javascript/pull/25891))
+- feat(deps): Bump lodash.omit from 4.5.0 to 4.18.0 ([#26055](https://github.com/getsentry/sentry-javascript/pull/26055))
+- feat(deps): Bump node-forge from 1.4.0 to 1.4.2 ([#26020](https://github.com/getsentry/sentry-javascript/pull/26020))
+- feat(deps): Bump pg and @types/pg ([#26042](https://github.com/getsentry/sentry-javascript/pull/26042))
+- feat(deps): Bump proxy from 2.1.1 to 4.1.0 ([#26029](https://github.com/getsentry/sentry-javascript/pull/26029))
+- feat(deps): Bump valibot from 1.4.2 to 1.5.0 ([#25957](https://github.com/getsentry/sentry-javascript/pull/25957))
+- feat(deps): Bump vite from 8.3.1 to 8.3.5 ([#26045](https://github.com/getsentry/sentry-javascript/pull/26045))
+- feat(deps): Bump yargs from 16.2.0 to 18.2.0 ([#26031](https://github.com/getsentry/sentry-javascript/pull/26031))
+- fix(build): Handle CRLF line endings in pnpm-workspace.yaml check ([#26083](https://github.com/getsentry/sentry-javascript/pull/26083))
+- fix(deps): Bump seroval to 1.6.12 and 1.7.7 ([#26047](https://github.com/getsentry/sentry-javascript/pull/26047))
+- fix(deps): Bump tar from 7.5.11 to 7.5.22 ([#26025](https://github.com/getsentry/sentry-javascript/pull/26025))
+- perf(ci): Increase tarball packing concurrency ([#25979](https://github.com/getsentry/sentry-javascript/pull/25979))
+- perf(ci): Speed up Next.js E2E tests by sharding into dev/prod ([#25960](https://github.com/getsentry/sentry-javascript/pull/25960))
+- perf(ci): Split Next.js application-key tests by environment ([#26040](https://github.com/getsentry/sentry-javascript/pull/26040))
+- ref(astro): Use convention constants for attribute keys ([#25894](https://github.com/getsentry/sentry-javascript/pull/25894))
+- ref(browser): Use convention constants for attribute keys ([#25895](https://github.com/getsentry/sentry-javascript/pull/25895))
+- ref(browser-utils): Use convention constants for attribute keys ([#25896](https://github.com/getsentry/sentry-javascript/pull/25896))
+- ref(bun): Use convention constants for attribute keys ([#25897](https://github.com/getsentry/sentry-javascript/pull/25897))
+- ref(cloudflare): Simplify shared SDK initialization ([#25864](https://github.com/getsentry/sentry-javascript/pull/25864))
+- ref(cloudflare): Use convention constants for attribute keys ([#25898](https://github.com/getsentry/sentry-javascript/pull/25898))
+- ref(core): Use convention constants for attribute keys ([#25899](https://github.com/getsentry/sentry-javascript/pull/25899))
+- ref(deno): Use convention constants for attribute keys ([#25900](https://github.com/getsentry/sentry-javascript/pull/25900))
+- ref(google-cloud-serverless): Use convention constants for attribute keys ([#25901](https://github.com/getsentry/sentry-javascript/pull/25901))
+- ref(nextjs): Use convention constants for attribute keys ([#25902](https://github.com/getsentry/sentry-javascript/pull/25902))
+- ref(node): Use convention constants for attribute keys ([#25903](https://github.com/getsentry/sentry-javascript/pull/25903))
+- ref(nuxt): Use convention constants for attribute keys ([#25904](https://github.com/getsentry/sentry-javascript/pull/25904))
+- ref(profiling-node): Use convention constants for attribute keys ([#25905](https://github.com/getsentry/sentry-javascript/pull/25905))
+- ref(react-router): Use convention constants for attribute keys ([#25906](https://github.com/getsentry/sentry-javascript/pull/25906))
+- ref(replay-internal): Use convention constants for attribute keys ([#25907](https://github.com/getsentry/sentry-javascript/pull/25907))
+- ref(server-utils): Clarify PostgreSQL instrumentation filenames ([#25939](https://github.com/getsentry/sentry-javascript/pull/25939))
+- ref(server-utils): Remove `requiresParentSpan` in favor of `onlyIfParent` ([#25926](https://github.com/getsentry/sentry-javascript/pull/25926))
+- ref(server-utils): Share the postgres.js query settlement logic ([#26094](https://github.com/getsentry/sentry-javascript/pull/26094))
+- ref(server-utils): Use convention constants for attribute keys ([#25908](https://github.com/getsentry/sentry-javascript/pull/25908))
+- ref(sveltekit): Use convention constants for attribute keys ([#25909](https://github.com/getsentry/sentry-javascript/pull/25909))
+- ref: Pass optional span attributes directly instead of conditional spreads ([#25878](https://github.com/getsentry/sentry-javascript/pull/25878))
+- test(aws-serverless): Move Lambda extension setup outside test timeout ([#25975](https://github.com/getsentry/sentry-javascript/pull/25975))
+- test(browser): Stabilize long animation frame tests ([#25974](https://github.com/getsentry/sentry-javascript/pull/25974))
+- test(bundler-plugins): Stabilize missing-parser test setup ([#25972](https://github.com/getsentry/sentry-javascript/pull/25972))
+- test(bundler-tests): Guard server runtime entries against build-time tooling ([#25724](https://github.com/getsentry/sentry-javascript/pull/25724))
+- test(e2e): Check node-openai-decisions through the event proxy ([#25840](https://github.com/getsentry/sentry-javascript/pull/25840))
+- test(e2e): Import Type from typebox in node-pi-durable ([#26018](https://github.com/getsentry/sentry-javascript/pull/26018))
+- test(e2e): Move canary variants into `canaryVariants` ([#25865](https://github.com/getsentry/sentry-javascript/pull/25865))
+- test(e2e): Move OpenAI and Anthropic send-to-sentry cases into gen-ai-libraries ([#25841](https://github.com/getsentry/sentry-javascript/pull/25841))
+- test(e2e): Point together-ai 1.x at OpenRouter's base URL in gen-ai-libraries ([#25947](https://github.com/getsentry/sentry-javascript/pull/25947))
+- test(e2e): Remove node-flue-send-to-sentry ([#25839](https://github.com/getsentry/sentry-javascript/pull/25839))
+- test(e2e): Rename nuxt-5 middleware files to avoid Nitro's dotted suffixes ([#25923](https://github.com/getsentry/sentry-javascript/pull/25923))
+- test(effect): Assert error severities without event ordering ([#25973](https://github.com/getsentry/sentry-javascript/pull/25973))
+- test(node-integration-tests): Install Flue and pi-durable as package dependencies ([#25933](https://github.com/getsentry/sentry-javascript/pull/25933))
+- test(node-integration-tests): Install Prisma suite dependencies as package dependencies ([#25943](https://github.com/getsentry/sentry-javascript/pull/25943))
+- test(node-integration-tests): Install single-version suite dependencies as package dependencies ([#25936](https://github.com/getsentry/sentry-javascript/pull/25936))
+- test(nuxt): cover the client plugin's pages-router instrumentation ([#25348](https://github.com/getsentry/sentry-javascript/pull/25348))
+- test(nuxt): Make the `nuxt-5 (tunnel-dev)` e2e variant optional ([#26004](https://github.com/getsentry/sentry-javascript/pull/26004))
+- test(nuxt): Use Nuxt's own Nitro and add db0 in nuxt-5 E2E app ([#26095](https://github.com/getsentry/sentry-javascript/pull/26095))
+
+
+
+Work in this release was contributed by @lazerg, @chiliec, @61465, @jpdefo, and @AyeshaWaseemAhmad. Thank you for your contributions!
+
## 11.10.0
### Important Changes
diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json
index 32d494be4e1d..af34bd6d41c5 100644
--- a/dev-packages/browser-integration-tests/package.json
+++ b/dev-packages/browser-integration-tests/package.json
@@ -55,30 +55,26 @@
"test:update-snapshots": "pnpm run test:all --update-snapshots",
"test:detect-flaky": "tsx scripts/detectFlakyTests.ts"
},
- "dependencies": {
+ "devDependencies": {
"@babel/core": "^7.29.6",
"@babel/preset-typescript": "^7.16.7",
"@playwright/test": "1.64.1",
- "@sentry/rrweb": "2.45.0",
"@sentry/browser": "11.10.0",
- "@sentry/replay": "11.10.0",
- "@sentry/opentelemetry": "11.10.0",
- "@sentry/conventions": "0.29.0",
- "@supabase/supabase-js": "2.109.0",
- "axios": "1.20.0",
- "babel-loader": "^10.1.1",
- "fflate": "0.8.3",
- "html-webpack-plugin": "^5.5.0",
- "webpack": "^5.95.0"
- },
- "devDependencies": {
"@sentry/browser-utils": "11.10.0",
+ "@sentry/conventions": "0.30.0",
"@sentry/core": "11.10.0",
+ "@sentry/replay": "11.10.0",
"@sentry/replay-canvas": "11.10.0",
+ "@sentry/rrweb": "2.45.0",
"@sentry/rrweb-types": "^2.45.0",
"@sentry/wasm": "11.10.0",
+ "@supabase/supabase-js": "2.109.0",
"@types/node": "^20.19.0",
- "eslint-plugin-regexp": "^3.1.0",
- "glob": "^13.0.6"
+ "axios": "1.21.0",
+ "babel-loader": "^10.1.1",
+ "fflate": "0.8.3",
+ "glob": "^14.0.3",
+ "html-webpack-plugin": "^5.5.0",
+ "webpack": "^5.112.2"
}
}
diff --git a/dev-packages/browser-integration-tests/suites/replay/errors/captureMessage/test.ts b/dev-packages/browser-integration-tests/suites/replay/errors/captureMessage/test.ts
new file mode 100644
index 000000000000..90b867c117e3
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/replay/errors/captureMessage/test.ts
@@ -0,0 +1,33 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { envelopeRequestParser, waitForErrorRequest } from '../../../../utils/helpers';
+import { getReplaySnapshot, shouldSkipReplayTest, waitForReplayRunning } from '../../../../utils/replayHelpers';
+
+sentryTest(
+ '[error-mode] should not flush for captureMessage when attachStacktrace is left at its default',
+ async ({ getLocalTestUrl, page, forceFlushReplay }) => {
+ if (shouldSkipReplayTest()) {
+ sentryTest.skip();
+ }
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ await page.goto(url);
+ await waitForReplayRunning(page);
+
+ const errorRequestPromise = waitForErrorRequest(page);
+ await page.evaluate(() => (window as any).Sentry.captureMessage('Some message'));
+ const event = envelopeRequestParser(await errorRequestPromise);
+ await forceFlushReplay();
+
+ expect(event.exception?.values?.[0]?.stacktrace?.frames?.length).toBeGreaterThan(0);
+ expect(event.tags?.replayId).toBeUndefined();
+ expect(await getReplaySnapshot(page)).toEqual(
+ expect.objectContaining({
+ _isEnabled: true,
+ _isPaused: false,
+ recordingMode: 'buffer',
+ }),
+ );
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/replay/errors/captureMessageAttachStacktrace/init.js b/dev-packages/browser-integration-tests/suites/replay/errors/captureMessageAttachStacktrace/init.js
new file mode 100644
index 000000000000..76d3c2afd889
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/replay/errors/captureMessageAttachStacktrace/init.js
@@ -0,0 +1,18 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+window.Replay = Sentry.replayIntegration({
+ flushMinDelay: 200,
+ flushMaxDelay: 200,
+ minReplayDuration: 0,
+});
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ sampleRate: 1,
+ replaysSessionSampleRate: 0.0,
+ replaysOnErrorSampleRate: 1.0,
+ attachStacktrace: true,
+
+ integrations: [window.Replay],
+});
diff --git a/dev-packages/browser-integration-tests/suites/replay/errors/captureMessageAttachStacktrace/test.ts b/dev-packages/browser-integration-tests/suites/replay/errors/captureMessageAttachStacktrace/test.ts
new file mode 100644
index 000000000000..d89371695436
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/replay/errors/captureMessageAttachStacktrace/test.ts
@@ -0,0 +1,36 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { envelopeRequestParser, waitForErrorRequest } from '../../../../utils/helpers';
+import {
+ getReplaySnapshot,
+ shouldSkipReplayTest,
+ waitForReplayRequest,
+ waitForReplayRunning,
+} from '../../../../utils/replayHelpers';
+
+sentryTest(
+ '[error-mode] should flush for captureMessage when attachStacktrace is explicitly enabled',
+ async ({ getLocalTestUrl, page }) => {
+ if (shouldSkipReplayTest()) {
+ sentryTest.skip();
+ }
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ await page.goto(url);
+ await waitForReplayRunning(page);
+
+ const errorRequestPromise = waitForErrorRequest(page);
+ const replayRequestPromise = waitForReplayRequest(page, 0);
+ await page.evaluate(() => (window as any).Sentry.captureMessage('Some message'));
+ const event = envelopeRequestParser(await errorRequestPromise);
+ await replayRequestPromise;
+
+ expect(event.tags?.replayId).toEqual(expect.any(String));
+ expect(await getReplaySnapshot(page)).toEqual(
+ expect.objectContaining({
+ recordingMode: 'session',
+ }),
+ );
+ },
+);
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 5881bf8b3ba0..72fce94d9815 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
@@ -10,7 +10,12 @@ import {
import type { Route } from '@playwright/test';
import { expect } from '@playwright/test';
import { sentryTest } from '../../../../utils/fixtures';
-import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers';
+import {
+ envelopeRequestParser,
+ shouldSkipTracingTest,
+ waitForNextFrame,
+ waitForTransactionRequest,
+} from '../../../../utils/helpers';
sentryTest(
'should capture long animation frame for top-level script.',
@@ -83,8 +88,11 @@ sentryTest(
await page.goto(url);
- // trigger long animation frame function
- await page.getByRole('button').click();
+ // Separate mousedown and click into different frames so LoAF reports the click handler.
+ await page.getByRole('button').hover();
+ await page.mouse.down();
+ await waitForNextFrame(page);
+ await page.mouse.up();
const eventData = envelopeRequestParser(await promise);
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 6a2638a61c45..93abdb679273 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
@@ -9,7 +9,7 @@ import {
import type { Route } from '@playwright/test';
import { expect } from '@playwright/test';
import { sentryTest } from '../../../../utils/fixtures';
-import { shouldSkipTracingTest } from '../../../../utils/helpers';
+import { shouldSkipTracingTest, waitForNextFrame } from '../../../../utils/helpers';
import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils';
sentryTest(
@@ -83,8 +83,11 @@ sentryTest('captures long animation frame span for event listener.', async ({ br
await page.goto(url);
- // trigger long animation frame function
- await page.getByRole('button').click();
+ // Separate mousedown and click into different frames so LoAF reports the click handler.
+ await page.getByRole('button').hover();
+ await page.mouse.down();
+ await waitForNextFrame(page);
+ await page.mouse.up();
const pageloadSpan = await pageloadSpanPromise;
await page.evaluate(() => (window as any).Sentry.flush());
diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts
index 90b3deae3cb9..60df3fd96eb2 100644
--- a/dev-packages/browser-integration-tests/utils/helpers.ts
+++ b/dev-packages/browser-integration-tests/utils/helpers.ts
@@ -564,6 +564,15 @@ export async function waitForLcpCandidate(page: Page, selector: string): Promise
});
}
+/**
+ * Resolves after the current frame has been presented (double rAF).
+ */
+export async function waitForNextFrame(page: Page): Promise {
+ await page.evaluate(
+ () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))),
+ );
+}
+
export async function hidePage(page: Page, interactionSelector?: string): Promise {
// web-vitals processes an interaction's event entries in `requestIdleCallback(..., { timeout:
// 1000 })`, and Chromium only reaches idle here once that timeout elapses. Hiding before that
diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts
index c62a8b2059ec..046e973354dd 100644
--- a/dev-packages/bun-integration-tests/node-suites/excludes.ts
+++ b/dev-packages/bun-integration-tests/node-suites/excludes.ts
@@ -7,6 +7,7 @@
// `node:sqlite`, which `flue` needs, and the Vercel keep-alive, which needs `http.server.response.finish`.
const NODE_ONLY = [
'suites/anr/test.ts',
+ 'suites/aws-lambda-web/**',
'suites/aws-serverless/**',
'suites/breadcrumbs/**',
'suites/child-process/test.ts',
@@ -64,6 +65,9 @@ const NO_FETCH_INSTRUMENTATION = [
// instrumentation creates no spans. Apps must be built with `@sentry/bun/plugin`.
// See https://github.com/getsentry/sentry-javascript/issues/23882
export const NO_AUTO_INSTRUMENTATION = [
+ 'suites/cron/cron-integration/test.ts',
+ 'suites/cron/node-cron/integration/test.ts',
+ 'suites/cron/node-schedule-integration/test.ts',
'suites/express/**',
'suites/fs-instrumentation/test.ts',
'suites/hono/test.ts',
@@ -71,7 +75,9 @@ export const NO_AUTO_INSTRUMENTATION = [
'suites/pino/test.ts',
'suites/tracing/amqplib/test.ts',
'suites/tracing/anthropic/test.ts',
+ 'suites/tracing/anthropic/v0/test.ts',
'suites/tracing/anthropic/v0.129/test.ts',
+ 'suites/tracing/anthropic/v1/test.ts',
'suites/tracing/apollo-graphql/**',
'suites/tracing/dataloader/test.ts',
'suites/tracing/fastify/test.ts',
@@ -92,6 +98,7 @@ export const NO_AUTO_INSTRUMENTATION = [
'suites/tracing/mastra/test.ts',
'suites/tracing/mcp-handler-exact-once/test.ts',
'suites/tracing/mcp-server/**',
+ 'suites/tracing/mistral-v2/test.ts',
'suites/tracing/mistral/test.ts',
'suites/tracing/mongodb-v4/test.ts',
'suites/tracing/mongodb-v5/test.ts',
@@ -211,6 +218,10 @@ const BUN_BUILD_EAGER_SUBSCRIPTION = ['suites/tracing/orchestrion-lazy-registrat
// `http.server.request.start`.
const BUN_BUILD_NO_HTTP_SERVER_INTEGRATION = ['suites/express/multiple-init/test.ts'];
+// `@mistralai/mistralai` v3 statically imports its optional peer `@livekit/rtc-node`, so Bun cannot
+// bundle the scenarios. `mistral-v2` still covers Mistral here.
+const BUN_BUILD_UNBUNDLEABLE_DEPENDENCY = ['suites/tracing/mistral/test.ts'];
+
// Some or all tests fail with the bundled scenarios. The cause is not investigated yet. In
// `express/tracing` only the request data tests fail: they set `httpIntegration` options, and with
// `@sentry/bun` the request body comes from `bunHttpServerIntegration`.
@@ -248,5 +259,6 @@ export const BUN_BUILD_EXCLUDE = [
'**/node_modules/**',
...BUN_BUILD_EAGER_SUBSCRIPTION,
...BUN_BUILD_NO_HTTP_SERVER_INTEGRATION,
+ ...BUN_BUILD_UNBUNDLEABLE_DEPENDENCY,
...BUN_BUILD_NOT_TRIAGED,
];
diff --git a/dev-packages/bun-integration-tests/package.json b/dev-packages/bun-integration-tests/package.json
index 5bf90187d898..1b63d888b452 100644
--- a/dev-packages/bun-integration-tests/package.json
+++ b/dev-packages/bun-integration-tests/package.json
@@ -13,20 +13,18 @@
"test": "vitest run",
"test:watch": "pnpm run test --watch"
},
- "dependencies": {
- "@sentry/bun": "11.10.0",
- "@sentry/hono": "11.10.0",
- "hono": "^4.13.7",
- "mysql": "^2.18.1",
- "pg": "8.16.0"
- },
"devDependencies": {
"@sentry-internal/node-integration-tests": "11.10.0",
"@sentry-internal/test-utils": "11.10.0",
+ "@sentry/bun": "11.10.0",
"@sentry/core": "11.10.0",
+ "@sentry/hono": "11.10.0",
"@types/mysql": "^2.15.27",
- "@types/pg": "^8.20.0",
+ "@types/pg": "^8.23.1",
"bun-types": "^1.2.9",
+ "hono": "^4.13.7",
+ "mysql": "^2.18.1",
+ "pg": "8.23.1",
"vitest": "^4.1.11"
}
}
diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/telemetry.test.ts
index fe5649ca57dc..2585278adc54 100644
--- a/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/telemetry.test.ts
+++ b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/telemetry.test.ts
@@ -6,7 +6,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => {
expect(readOutputFiles()).toMatchInlineSnapshot(`
{
"sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]],
- [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"esbuild","bundler-major-version":"28"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]],
+ [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"delete-sourcemaps-after-upload":"auto","sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"esbuild","bundler-major-version":"28"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]],
[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]],
",
"telemetry.js": "(() => {
diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/telemetry.test.ts
index 77ac826d8d70..f995682ca797 100644
--- a/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/telemetry.test.ts
+++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/telemetry.test.ts
@@ -18,7 +18,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => {
//#endregion
",
"sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]],
- [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"rollup","bundler-major-version":"4"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]],
+ [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"delete-sourcemaps-after-upload":"auto","sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"rollup","bundler-major-version":"4"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]],
[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]],
",
}
diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/telemetry.test.ts
index 329f3b6969ed..72ae140b7b42 100644
--- a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/telemetry.test.ts
+++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/telemetry.test.ts
@@ -9,7 +9,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => {
!function(){try{var e="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:{};e.SENTRY_RELEASE={id:"CURRENT_SHA"};var n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="00000000-0000-0000-0000-000000000000",e._sentryDebugIdIdentifier="sentry-dbid-00000000-0000-0000-0000-000000000000");}catch(e){}}();console.log("hello world");
",
"sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]],
- [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"rollup","bundler-major-version":"3"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]],
+ [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"delete-sourcemaps-after-upload":"auto","sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"rollup","bundler-major-version":"3"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]],
[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]],
",
}
diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/telemetry.test.ts
index 4e0c4d969660..d4bde5c071ae 100644
--- a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/telemetry.test.ts
+++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/telemetry.test.ts
@@ -9,7 +9,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => {
!function(){try{var e="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:{};e.SENTRY_RELEASE={id:"CURRENT_SHA"};var n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="00000000-0000-0000-0000-000000000000",e._sentryDebugIdIdentifier="sentry-dbid-00000000-0000-0000-0000-000000000000");}catch(e){}}();console.log("hello world");
",
"sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]],
- [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"rollup","bundler-major-version":"4"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]],
+ [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"delete-sourcemaps-after-upload":"auto","sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"rollup","bundler-major-version":"4"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]],
[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]],
",
}
diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/telemetry.test.ts
index d6ea3f3a91d5..f61dde5478e3 100644
--- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/telemetry.test.ts
+++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/telemetry.test.ts
@@ -17,7 +17,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => {
console.log("hello world");
",
"sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]],
- [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"vite","bundler-major-version":"4"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]],
+ [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"delete-sourcemaps-after-upload":"auto","sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"vite","bundler-major-version":"4"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]],
[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]],
",
}
diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/telemetry.test.ts
index 3b5c50c6a113..9ad711e030fc 100644
--- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/telemetry.test.ts
+++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/telemetry.test.ts
@@ -17,7 +17,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => {
console.log("hello world");
",
"sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]],
- [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"vite","bundler-major-version":"7"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]],
+ [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"delete-sourcemaps-after-upload":"auto","sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"vite","bundler-major-version":"7"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]],
[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]],
",
}
diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/telemetry.test.ts
index d09ee8bc094a..6d832d2ae117 100644
--- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/telemetry.test.ts
+++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/telemetry.test.ts
@@ -18,7 +18,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => {
//#endregion
",
"sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]],
- [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"vite","bundler-major-version":"8"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]],
+ [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"delete-sourcemaps-after-upload":"auto","sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"vite","bundler-major-version":"8"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]],
[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]],
",
}
diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/telemetry.test.ts
index 0854328dd4a8..22d582168680 100644
--- a/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/telemetry.test.ts
+++ b/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/telemetry.test.ts
@@ -13,7 +13,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => {
/******/ })()
;",
"sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]],
- [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"webpack","bundler-major-version":"5"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]],
+ [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"delete-sourcemaps-after-upload":"auto","sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"webpack","bundler-major-version":"5"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]],
[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]],
",
}
diff --git a/dev-packages/bundler-plugin-integration-tests/package.json b/dev-packages/bundler-plugin-integration-tests/package.json
index df87dbd53c3a..b4f44b5923e7 100644
--- a/dev-packages/bundler-plugin-integration-tests/package.json
+++ b/dev-packages/bundler-plugin-integration-tests/package.json
@@ -11,10 +11,8 @@
"clean:build": "premove ./fixtures/*/out",
"clean:deps": "premove node_modules"
},
- "dependencies": {
- "@sentry/bundler-plugins": "11.10.0"
- },
"devDependencies": {
+ "@sentry/bundler-plugins": "11.10.0",
"premove": "^4.0.0",
"sentry": "^0.45.0",
"typescript": "~6.0.3",
diff --git a/dev-packages/bundler-tests/package.json b/dev-packages/bundler-tests/package.json
index 9f119ab94f65..7baa238d34a6 100644
--- a/dev-packages/bundler-tests/package.json
+++ b/dev-packages/bundler-tests/package.json
@@ -11,13 +11,27 @@
"scripts": {
"test": "vitest run"
},
- "dependencies": {
+ "devDependencies": {
"@rollup/plugin-node-resolve": "^16.0.3",
+ "@sentry/astro": "11.10.0",
"@sentry/browser": "11.10.0",
+ "@sentry/bun": "11.10.0",
+ "@sentry/cloudflare": "11.10.0",
+ "@sentry/deno": "11.10.0",
+ "@sentry/nextjs": "11.10.0",
+ "@sentry/node": "11.10.0",
+ "@sentry/nuxt": "11.10.0",
+ "@sentry/react-router": "11.10.0",
+ "@sentry/remix": "11.10.0",
+ "@sentry/solidstart": "11.10.0",
+ "@sentry/sveltekit": "11.10.0",
+ "@sentry/tanstackstart-react": "11.10.0",
+ "@sentry/vercel-edge": "11.10.0",
+ "@vercel/nft": "^1.11.0",
"rollup": "^4.60.3",
- "vite": "^8.3.1",
+ "vite": "^8.3.5",
"vitest": "^4.1.11",
- "webpack": "^5.0.0"
+ "webpack": "^5.112.2"
},
"type": "module"
}
diff --git a/dev-packages/bundler-tests/tests/runtime-entries.test.ts b/dev-packages/bundler-tests/tests/runtime-entries.test.ts
new file mode 100644
index 000000000000..156102cc6765
--- /dev/null
+++ b/dev-packages/bundler-tests/tests/runtime-entries.test.ts
@@ -0,0 +1,279 @@
+import { describe, expect, test } from 'vitest';
+import * as path from 'node:path';
+import * as fs from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+import { nodeFileTrace, resolve } from '@vercel/nft';
+
+/**
+ * Server runtime entries must not reach build-time tooling. When they do,
+ * the tooling ends up in server bundles and traced outputs (Next standalone,
+ * Nitro, workerd, ...), where it breaks in a different way for each
+ * consumer. `@vercel/nft` is the tracer that Next standalone and the Vercel
+ * adapters use, so it reports what those consumers would ship.
+ */
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT_DIR = path.join(__dirname, '../../..');
+const FIXTURE_DIR = path.join(__dirname, '..', 'dist', 'runtime-entries');
+
+const BUILD_TIME_PATTERNS: RegExp[] = [
+ /(^|\/)packages\/bundler-plugins\//,
+ /(^|\/)node_modules\/@sentry\/bundler-plugins(\/|$)/,
+ /(^|\/)packages\/server-utils\/build\/[^/]+\/orchestrion\/bundler\//,
+ /(^|\/)node_modules\/(vite|webpack|rollup|esbuild|oxc-parser)\//,
+ /(^|\/)node_modules\/@(babel|rollup|oxc-parser)\//,
+ /\.wasm$/,
+];
+
+const KNOWN_VIOLATIONS = new Set([
+ // These root entries also re-export config-time code (`sentryAstro`,
+ // `withSentry`). They must keep it until v12 for backwards compatibility.
+ '@sentry/astro',
+ '@sentry/solidstart',
+ // Remix 3 has no bundler. Its asset server runs the orchestrion
+ // transform on browser modules at request time.
+ '@sentry/remix/v3',
+ '@sentry/remix/v3/node',
+]);
+
+type Format = 'esm' | 'cjs';
+
+interface Case {
+ specifier: string;
+ conditions: string[];
+ formats: Format[];
+}
+
+const NODE = ['node'];
+const WORKERD = ['workerd', 'worker'];
+const BOTH: Format[] = ['esm', 'cjs'];
+const ESM: Format[] = ['esm'];
+
+const CASES: Case[] = [
+ { specifier: '@sentry/node', conditions: NODE, formats: BOTH },
+ { specifier: '@sentry/node/import', conditions: NODE, formats: ESM },
+ { specifier: '@sentry/bun', conditions: ['bun', 'node'], formats: BOTH },
+ { specifier: '@sentry/deno', conditions: ['deno', 'node'], formats: ESM },
+ { specifier: '@sentry/vercel-edge', conditions: ['edge-light'], formats: BOTH },
+ { specifier: '@sentry/cloudflare', conditions: WORKERD, formats: BOTH },
+ { specifier: '@sentry/cloudflare', conditions: [...WORKERD, 'development'], formats: BOTH },
+ { specifier: '@sentry/cloudflare/request', conditions: WORKERD, formats: BOTH },
+ { specifier: '@sentry/cloudflare/orchestrion-diagnostics-channel', conditions: WORKERD, formats: BOTH },
+ { specifier: '@sentry/deno/import', conditions: ['deno', 'node'], formats: ESM },
+
+ { specifier: '@sentry/nextjs', conditions: NODE, formats: BOTH },
+ { specifier: '@sentry/nextjs', conditions: ['edge-light'], formats: BOTH },
+ { specifier: '@sentry/nextjs', conditions: WORKERD, formats: BOTH },
+ { specifier: '@sentry/nextjs/cloudflare', conditions: WORKERD, formats: BOTH },
+ { specifier: '@sentry/nextjs/import', conditions: NODE, formats: ESM },
+ { specifier: '@sentry/nextjs/tunnel', conditions: NODE, formats: BOTH },
+ { specifier: '@sentry/nextjs/tunnel', conditions: ['edge-light'], formats: BOTH },
+ { specifier: '@sentry/nextjs/async-storage-shim', conditions: NODE, formats: BOTH },
+ { specifier: '@sentry/nextjs/orchestrion-runtime/index', conditions: NODE, formats: BOTH },
+ { specifier: '@sentry/nextjs/orchestrion-runtime/register', conditions: NODE, formats: BOTH },
+
+ { specifier: '@sentry/nuxt', conditions: NODE, formats: BOTH },
+ { specifier: '@sentry/nuxt/module/plugins', conditions: NODE, formats: ESM },
+
+ { specifier: '@sentry/sveltekit', conditions: NODE, formats: BOTH },
+ { specifier: '@sentry/sveltekit', conditions: WORKERD, formats: BOTH },
+ { specifier: '@sentry/sveltekit', conditions: ['worker'], formats: BOTH },
+ { specifier: '@sentry/sveltekit/opentelemetry-api', conditions: NODE, formats: BOTH },
+
+ { specifier: '@sentry/astro', conditions: NODE, formats: BOTH },
+ { specifier: '@sentry/astro/middleware', conditions: NODE, formats: BOTH },
+ { specifier: '@sentry/astro/import', conditions: NODE, formats: ESM },
+
+ { specifier: '@sentry/react-router', conditions: NODE, formats: BOTH },
+ { specifier: '@sentry/react-router', conditions: WORKERD, formats: BOTH },
+ { specifier: '@sentry/react-router/cloudflare', conditions: WORKERD, formats: BOTH },
+
+ { specifier: '@sentry/remix', conditions: NODE, formats: BOTH },
+ { specifier: '@sentry/remix', conditions: ['worker'], formats: BOTH },
+ { specifier: '@sentry/remix/cloudflare', conditions: WORKERD, formats: BOTH },
+ { specifier: '@sentry/remix/import', conditions: NODE, formats: ESM },
+ { specifier: '@sentry/remix/v3', conditions: NODE, formats: ESM },
+ { specifier: '@sentry/remix/v3/node', conditions: NODE, formats: ESM },
+
+ { specifier: '@sentry/solidstart', conditions: NODE, formats: BOTH },
+
+ { specifier: '@sentry/tanstackstart-react', conditions: NODE, formats: BOTH },
+ { specifier: '@sentry/tanstackstart-react/import', conditions: NODE, formats: ESM },
+];
+
+/**
+ * Build-time entries must reach build-time tooling. If one does not, then
+ * `BUILD_TIME_PATTERNS` no longer matches the tooling that we use.
+ */
+const BUILD_TIME_CASES: Case[] = [
+ { specifier: '@sentry/node/vite', conditions: NODE, formats: BOTH },
+ { specifier: '@sentry/node/rollup', conditions: NODE, formats: BOTH },
+ { specifier: '@sentry/node/webpack', conditions: NODE, formats: BOTH },
+ { specifier: '@sentry/node/esbuild', conditions: NODE, formats: BOTH },
+ { specifier: '@sentry/bun/plugin', conditions: NODE, formats: BOTH },
+ { specifier: '@sentry/cloudflare/vite', conditions: NODE, formats: BOTH },
+ { specifier: '@sentry/nextjs/config', conditions: NODE, formats: BOTH },
+ { specifier: '@sentry/nuxt/module', conditions: NODE, formats: ESM },
+ { specifier: '@sentry/sveltekit/vite', conditions: NODE, formats: BOTH },
+ { specifier: '@sentry/astro/integration', conditions: NODE, formats: BOTH },
+ { specifier: '@sentry/react-router/vite', conditions: NODE, formats: BOTH },
+ { specifier: '@sentry/remix/vite', conditions: NODE, formats: BOTH },
+ { specifier: '@sentry/solidstart/vite', conditions: NODE, formats: BOTH },
+ { specifier: '@sentry/tanstackstart-react/vite', conditions: NODE, formats: BOTH },
+];
+
+/** Browser entries of the packages above. This test does not check them. */
+const CLIENT_ENTRIES = new Set([
+ '@sentry/sveltekit/browser-tracing-variant',
+ '@sentry/remix/v3/client',
+ '@sentry/solidstart/solidrouter',
+]);
+
+function writeFixture(specifier: string, conditions: string[], format: Format): string {
+ fs.mkdirSync(FIXTURE_DIR, { recursive: true });
+ const name = `${specifier.replace(/[@/]/g, '_')}-${conditions.join('_')}.${format === 'esm' ? 'mjs' : 'cjs'}`;
+ const file = path.join(FIXTURE_DIR, name);
+ fs.writeFileSync(file, format === 'esm' ? `import '${specifier}';\n` : `require('${specifier}');\n`);
+ return file;
+}
+
+function isBuildTime(file: string): boolean {
+ return BUILD_TIME_PATTERNS.some(pattern => pattern.test(file));
+}
+
+type Reasons = Awaited>['reasons'];
+
+// Most entries share `@sentry/node` and its dependencies, so sharing the
+// file and analysis cache saves most of the work.
+const traceCache = {};
+
+/** The shortest import chain from `entry` to `file`, both relative to `ROOT_DIR`. */
+function importChain(reasons: Reasons, entry: string, file: string): string[] {
+ const importedBy = new Map([[file, file]]);
+ const queue = [file];
+ while (queue.length) {
+ const current = queue.shift() as string;
+ if (current === entry) {
+ const chain = [current];
+ for (let link = current; link !== file;) {
+ link = importedBy.get(link) as string;
+ chain.push(link);
+ }
+ return chain;
+ }
+ for (const parent of reasons.get(current)?.parents ?? []) {
+ if (!importedBy.has(parent)) {
+ importedBy.set(parent, current);
+ queue.push(parent);
+ }
+ }
+ }
+ return [file];
+}
+
+/**
+ * Each place where a runtime module imports a build-time module, as the
+ * import chain from the entry.
+ */
+async function findBuildTimeImports(fixture: string, conditions: string[]): Promise {
+ const imports: [importer: string, file: string][] = [];
+
+ const { fileList, reasons } = await nodeFileTrace([fixture], {
+ base: ROOT_DIR,
+ conditions,
+ cache: traceCache,
+ // A dynamic `fs` path in config code makes nft glob the whole repo.
+ // That is slow and finds user files, not package code.
+ analysis: { emitGlobs: false },
+ // Stop at the first build-time module, instead of tracing all of Vite
+ // or Babel. `.wasm` files are assets, so they stay in the trace with
+ // their importer.
+ ignore: file => isBuildTime(file) && !file.endsWith('.wasm'),
+ async resolve(id, parent, job, cjsResolve) {
+ const resolved = await resolve(id, parent, job, cjsResolve);
+ for (const file of [resolved].flat()) {
+ if (isBuildTime(file)) {
+ imports.push([path.relative(ROOT_DIR, parent), path.relative(ROOT_DIR, file)]);
+ }
+ }
+ return resolved;
+ },
+ });
+
+ for (const file of fileList) {
+ if (file.endsWith('.wasm')) {
+ for (const parent of reasons.get(file)?.parents ?? []) {
+ imports.push([parent, file]);
+ }
+ }
+ }
+
+ const entry = path.relative(ROOT_DIR, fixture);
+ const violations = imports.map(([importer, file]) =>
+ importChain(reasons, entry, importer).concat(file).join('\n -> '),
+ );
+ return [...new Set(violations)].sort();
+}
+
+describe('server runtime entries do not reach build-time tooling', () => {
+ for (const { specifier, conditions, formats } of CASES) {
+ for (const format of formats) {
+ const name = `${specifier} (${format}, conditions: ${conditions.join(', ')})`;
+
+ if (KNOWN_VIOLATIONS.has(specifier)) {
+ test(`${name} is a known violation`, async () => {
+ const violations = await findBuildTimeImports(writeFixture(specifier, conditions, format), conditions);
+ // Remove the entry from KNOWN_VIOLATIONS when this fails.
+ expect(violations).not.toEqual([]);
+ });
+ } else {
+ test(name, async () => {
+ const violations = await findBuildTimeImports(writeFixture(specifier, conditions, format), conditions);
+ expect(violations).toEqual([]);
+ });
+ }
+ }
+ }
+});
+
+describe('build-time entries reach build-time tooling', () => {
+ for (const { specifier, conditions, formats } of BUILD_TIME_CASES) {
+ for (const format of formats) {
+ test(`${specifier} (${format}, conditions: ${conditions.join(', ')})`, async () => {
+ const violations = await findBuildTimeImports(writeFixture(specifier, conditions, format), conditions);
+ expect(violations).not.toEqual([]);
+ });
+ }
+ }
+});
+
+test('every export of a tested package is classified', () => {
+ const classified = new Set([...CASES, ...BUILD_TIME_CASES].map(({ specifier }) => specifier));
+ for (const specifier of CLIENT_ENTRIES) {
+ classified.add(specifier);
+ }
+ const packages = new Set([...classified].map(specifier => specifier.split('/').slice(0, 2).join('/')));
+
+ const unclassified: string[] = [];
+ for (const pkg of packages) {
+ const manifest = path.join(ROOT_DIR, 'packages', pkg.replace('@sentry/', ''), 'package.json');
+ const { exports } = JSON.parse(fs.readFileSync(manifest, 'utf8')) as { exports: Record };
+ for (const subpath of Object.keys(exports)) {
+ if (subpath === './package.json') {
+ continue;
+ }
+ const specifier = pkg + subpath.slice(1);
+ const [prefix, suffix] = specifier.split('*');
+ const found =
+ suffix === undefined
+ ? classified.has(specifier)
+ : [...classified].some(s => s.startsWith(prefix as string) && s.endsWith(suffix));
+ if (!found) {
+ unclassified.push(specifier);
+ }
+ }
+ }
+ // Add each one to CASES, BUILD_TIME_CASES, or CLIENT_ENTRIES.
+ expect(unclassified).toEqual([]);
+});
diff --git a/dev-packages/clear-cache-gh-action/package.json b/dev-packages/clear-cache-gh-action/package.json
index a6ffee8b7ba8..c5fd1253c0c0 100644
--- a/dev-packages/clear-cache-gh-action/package.json
+++ b/dev-packages/clear-cache-gh-action/package.json
@@ -13,11 +13,8 @@
"lint": "oxlint . --type-aware",
"lint:fix": "oxlint . --fix --type-aware"
},
- "dependencies": {
- "@actions/core": "1.10.1",
- "@actions/github": "^5.0.0"
- },
"devDependencies": {
- "eslint-plugin-regexp": "^3.1.0"
+ "@actions/core": "3.0.1",
+ "@actions/github": "^9.1.1"
}
}
diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json
index 9fe7f72dda29..1e81fd479ed6 100644
--- a/dev-packages/cloudflare-integration-tests/package.json
+++ b/dev-packages/cloudflare-integration-tests/package.json
@@ -13,34 +13,31 @@
"test": "vitest run",
"test:watch": "pnpm run test --watch"
},
- "dependencies": {
- "ai": "^6.0.0",
+ "devDependencies": {
"@anthropic-ai/sdk": "0.133.0",
+ "@cloudflare/vite-plugin": "1.34.0",
+ "@cloudflare/workers-types": "^4.20260426.0",
+ "@cloudflare/workers-types-v5": "npm:@cloudflare/workers-types@5.20260710.1",
"@google/genai": "^1.20.0",
"@langchain/core": "^1.2.17",
"@langchain/langgraph": "^1.0.1",
"@langchain/openai": "^1.6.2",
"@langchain/typesafe": "^0.0.2",
+ "@opentelemetry/api": "^1.9.1",
"@prisma/adapter-d1": "6.15.0",
"@prisma/client": "6.15.0",
+ "@sentry-internal/test-utils": "11.10.0",
"@sentry/cloudflare": "11.10.0",
+ "@sentry/conventions": "0.30.0",
"@sentry/core": "11.10.0",
"@sentry/hono": "11.10.0",
+ "ai": "^6.0.0",
"hono": "^4.13.7",
"langchain": "^1.5.16",
- "openai": "5.18.1"
- },
- "devDependencies": {
- "@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.10.0",
- "@sentry/conventions": "0.29.0",
- "eslint-plugin-regexp": "^3.1.0",
+ "openai": "5.18.1",
"prisma": "6.15.0",
- "vite": "^8.3.1",
+ "vite": "^8.3.5",
"vitest": "^4.1.11",
- "wrangler": "4.86.0"
+ "wrangler": "4.156.0"
}
}
diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/test.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/test.ts
index 0fa5d410368b..adf52419022f 100644
--- a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/test.ts
+++ b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/test.ts
@@ -1,5 +1,6 @@
import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE } from '@sentry/core';
import {
+ CLIENT_ADDRESS,
SENTRY_SEGMENT_NAME_SOURCE,
SENTRY_SDK_NAME,
SENTRY_SDK_VERSION,
@@ -205,6 +206,15 @@ it('sends a streamed span envelope with correct spans for a manually started spa
type: 'string',
value: 'localhost',
},
+ // From the `CF-Connecting-IP` header that `wrangler dev` sets.
+ [CLIENT_ADDRESS]: {
+ type: 'string',
+ value: '127.0.0.1',
+ },
+ 'http.response.header.content-type': {
+ type: 'array',
+ value: ['text/plain;charset=UTF-8'],
+ },
'url.full': {
type: 'string',
value: expect.stringMatching(/^http:\/\/localhost:.+$/),
diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/test.ts
index 596ae6c6ef6d..27e35a07270f 100644
--- a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/test.ts
+++ b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/test.ts
@@ -1,16 +1,19 @@
-import type { Event } from '@sentry/core';
+import { HTTP_SERVER } from '@sentry/conventions/op';
import { expect, it } from 'vitest';
import { createRunner } from '../../../../runner';
+import { getSpanOp, getSpansFromEnvelope } from '../../../../spanUtils';
it('does not capture spans emitted through @opentelemetry/api inside wrapRequestHandler from the /request subpath', async ({
signal,
}) => {
const runner = createRunner(__dirname)
.expect(envelope => {
- const event = envelope[1]?.[0]?.[1] as Event;
- expect(event.transaction).toBe('GET /');
- expect(event.contexts?.trace?.op).toBe('http.server');
- expect(event.spans).toEqual([]);
+ // Only the request's segment span. The OpenTelemetry span is not captured.
+ const spans = getSpansFromEnvelope(envelope);
+ expect(spans).toHaveLength(1);
+ expect(spans[0]?.is_segment).toBe(true);
+ expect(spans[0]?.name).toBe('GET /');
+ expect(getSpanOp(spans[0]!)).toBe(HTTP_SERVER);
})
.start(signal);
diff --git a/dev-packages/deno-integration-tests/node-suites/excludes.ts b/dev-packages/deno-integration-tests/node-suites/excludes.ts
index b93a8ff63a65..e6d5906bc2c8 100644
--- a/dev-packages/deno-integration-tests/node-suites/excludes.ts
+++ b/dev-packages/deno-integration-tests/node-suites/excludes.ts
@@ -2,8 +2,13 @@
// fails on Deno runs only on the other runtimes, with `test.runIf(supports({ runtimes }))` in the
// Node suite, and is not listed here.
-// Node-only features: ANR and native thread watchdogs, child process and worker thread breadcrumbs.
-const NODE_ONLY = ['suites/anr/test.ts', 'suites/breadcrumbs/**', 'suites/thread-blocked-native/test.ts'];
+// Node-only features: ANR and native thread watchdogs, child process and worker thread breadcrumbs, and the AWS Lambda Node runtime.
+const NODE_ONLY = [
+ 'suites/anr/test.ts',
+ 'suites/aws-lambda-web/**',
+ 'suites/breadcrumbs/**',
+ 'suites/thread-blocked-native/test.ts',
+];
// `@sentry/node` instruments `fetch` through undici's diagnostics channels, which Deno's `fetch`
// does not publish. `@sentry/deno` has its own `fetchIntegration` for this. These suites check
@@ -62,6 +67,7 @@ const NOT_TRIAGED = [
'suites/tracing/koa/test.ts',
'suites/tracing/langchain/test.ts',
'suites/tracing/langgraph/test.ts',
+ 'suites/tracing/mistral-v2/test.ts',
'suites/tracing/mistral/test.ts',
'suites/tracing/mongodb/test.ts',
'suites/tracing/mongoose-v5/test.ts',
diff --git a/dev-packages/deno-integration-tests/package.json b/dev-packages/deno-integration-tests/package.json
index 67f24f8c0f8f..19b9c21d536a 100644
--- a/dev-packages/deno-integration-tests/package.json
+++ b/dev-packages/deno-integration-tests/package.json
@@ -15,15 +15,13 @@
"test:unit": "deno test --allow-net --allow-read --allow-run --allow-env --no-check suites",
"test:node-suites": "vitest run"
},
- "dependencies": {
+ "devDependencies": {
+ "@sentry-internal/node-integration-tests": "11.10.0",
+ "@sentry/conventions": "^0.30.0",
"@sentry/core": "11.10.0",
"@sentry/deno": "11.10.0",
"mysql": "^2.18.1",
- "pg": "^8.22.0"
- },
- "devDependencies": {
- "@sentry-internal/node-integration-tests": "11.10.0",
- "@sentry/conventions": "^0.29.0",
+ "pg": "^8.23.1",
"vitest": "^4.1.11"
}
}
diff --git a/dev-packages/e2e-tests/.env.example b/dev-packages/e2e-tests/.env.example
index 3815cae556f0..17326f227680 100644
--- a/dev-packages/e2e-tests/.env.example
+++ b/dev-packages/e2e-tests/.env.example
@@ -17,7 +17,7 @@ E2E_TEST_SENTRY_PROJECT=
E2E_OPENROUTER_API_KEY=
# An OpenAI API key for the Decisions API (OpenRouter has no Decisions route).
-# Only needed to run node-openai-decisions-send-to-sentry locally.
+# Only needed to run node-openai-decisions locally.
E2E_OPENAI_DECISIONS_API_KEY=
# Cloudflare credentials for E2E tests that deploy a real Worker (e.g. cloudflare-workers-send-to-sentry).
diff --git a/dev-packages/e2e-tests/README.md b/dev-packages/e2e-tests/README.md
index ce769d3c87f8..be2b94586ece 100644
--- a/dev-packages/e2e-tests/README.md
+++ b/dev-packages/e2e-tests/README.md
@@ -193,6 +193,12 @@ is the reference setup.
because workerd does not allow it. `nextjs-16` has no Vite build: its Cloudflare variant builds with
`opennextjs-cloudflare build --config wrangler.opennext.jsonc` and wraps the generated Worker in `worker.ts` with
`withSentry` from `@sentry/nextjs/cloudflare`.
+- **Cloudflare in `vite dev`**: to run the Worker in `vite dev` (for example the dev tests of the `hono-4` Cloudflare
+ variant), start it with `CLOUDFLARE_INCLUDE_PROCESS_ENV=true vite dev`. The runner gives `E2E_TEST_DSN` to the app
+ only as a process env var. `wrangler dev` adds it to the Worker `env` with `--var "E2E_TEST_DSN:$E2E_TEST_DSN"`, but
+ `@cloudflare/vite-plugin` has no such option. It fills the Worker `env` from the `vars` of the wrangler config and
+ from `.dev.vars` or `.env`, and adds process env vars only when `CLOUDFLARE_INCLUDE_PROCESS_ENV` is `true`. Without
+ it, `env.E2E_TEST_DSN` is `undefined`, the SDK sends no events, and the tests fail.
- **Build flavors**: a variant can also build the same app with another tool, for example `nextjs-16 (vinext)` builds
`nextjs-16` with vinext. A build flavor is not a runtime: its assert script sets its own env var (`VINEXT=1`, read by
`tests/isVinext.ts`). It sets `runtime` only to run on another runtime, for example `nextjs-16 (vinext, bun)`.
@@ -286,9 +292,21 @@ 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.
+`sentryTest.baseVariant` overrides the default matrix entry using the same fields as an entry in `variants`. For example,
+set its `assert-command` to run production tests and add a development variant to run both environments in parallel.
+The local `test:assert` script can still run both; select the base variant locally with `--variant `.
+
+Variants that build against upstream prereleases (for example `canary`, `next` or nightly builds of a framework) go into
+`canaryVariants`. They do not run on PRs, only in the nightly `CI: Canary Tests` workflow (`.github/workflows/canary.yml`),
+so failures caused by an upstream prerelease do not show up on unrelated PRs. Run one locally with
+`pnpm run test:run --variant `. Variants that build against the `latest` release belong in
+`optionalVariants`.
+
+```json
+"sentryTest": {
+ "canaryVariants": [{ "build-command": "pnpm test:build-canary", "label": "my-app (canary)" }]
+}
+```
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/getTestMatrix.mjs b/dev-packages/e2e-tests/lib/getTestMatrix.mjs
index cec2cb530828..447ff5ad6f01 100644
--- a/dev-packages/e2e-tests/lib/getTestMatrix.mjs
+++ b/dev-packages/e2e-tests/lib/getTestMatrix.mjs
@@ -6,14 +6,28 @@ import { parseArgs } from 'node:util';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
+// GitHub Actions fails a job whose matrix has more jobs than this, and runs none of them.
+const MAX_MATRIX_JOBS = 256;
+
+const VARIANT_FIELDS = {
+ required: 'variants',
+ optional: 'optionalVariants',
+ canary: 'canaryVariants',
+};
+
/**
* Generates a matrix for the GitHub Actions workflow to run the E2E tests.
* Checks which test applications are affected by the current changes in the PR and then generates a matrix
* including all test apps that have at least one dependency that was changed in the PR.
* If no `--base=xxx` is provided, it will output all test applications.
*
- * If `--optional=true` is set, it will generate a matrix of optional test applications only.
- * Otherwise, these will be skipped.
+ * `--variant` selects which matrix to generate:
+ * - not set: the required test applications and their `variants`
+ * - `optional`: the optional test applications and the `optionalVariants` of all test applications
+ * - `canary`: only the `canaryVariants` of all test applications
+ *
+ * `--shards=N` splits the matrix into N parts (`matrix-1=...` to `matrix-N=...`), because GitHub Actions allows at
+ * most 256 jobs in one matrix.
*/
function run() {
const { values } = parseArgs({
@@ -21,15 +35,20 @@ function run() {
options: {
base: { type: 'string' },
head: { type: 'string' },
- optional: { type: 'string', default: 'false' },
+ variant: { type: 'string', default: 'required' },
+ shards: { type: 'string' },
},
});
- const { base, head = 'HEAD', optional } = values;
+ const { base, head = 'HEAD', variant, shards } = values;
// For GitHub Action debugging
// eslint-disable-next-line no-console
- console.error(`Parsed command line arguments: base=${base}, head=${head}, optional=${optional}`);
+ console.error(`Parsed command line arguments: base=${base}, head=${head}, variant=${variant}`);
+
+ if (!Object.hasOwn(VARIANT_FIELDS, variant)) {
+ throw new Error(`Unknown --variant=${variant}, expected one of: ${Object.keys(VARIANT_FIELDS).join(', ')}`);
+ }
const testApplications = discoverTestApplicationDirs();
@@ -43,16 +62,33 @@ function run() {
? getAffectedTestApplications(testApplications, { base, head })
: testApplications;
- const optionalMode = optional === 'true';
const includes = [];
includedTestApplications.forEach(testApp => {
- addIncludesForTestApp(testApp, includes, { optionalMode });
+ addIncludesForTestApp(testApp, includes, { variant });
});
- // We print this to the output, so the GHA can use it for the matrix
- // eslint-disable-next-line no-console
- console.log(`matrix=${JSON.stringify({ include: includes })}`);
+ if (!shards) {
+ // We print this to the output, so the GHA can use it for the matrix
+ // eslint-disable-next-line no-console
+ console.log(`matrix=${JSON.stringify({ include: includes })}`);
+ return;
+ }
+
+ const shardCount = Number(shards);
+ const shardSize = Math.ceil(includes.length / shardCount);
+
+ if (shardSize > MAX_MATRIX_JOBS) {
+ throw new Error(
+ `${includes.length} jobs do not fit into ${shardCount} matrices of at most ${MAX_MATRIX_JOBS} jobs, increase --shards`,
+ );
+ }
+
+ for (let i = 0; i < shardCount; i++) {
+ const shardIncludes = includes.slice(i * shardSize, (i + 1) * shardSize);
+ // eslint-disable-next-line no-console
+ console.log(`matrix-${i + 1}=${JSON.stringify({ include: shardIncludes })}`);
+ }
}
/** Direct children of `test-applications/` that contain a `package.json` (replaces glob one-segment + package.json). */
@@ -66,22 +102,22 @@ function discoverTestApplicationDirs() {
.sort();
}
-function addIncludesForTestApp(testApp, includes, { optionalMode }) {
+function addIncludesForTestApp(testApp, includes, { variant }) {
const packageJson = getPackageJson(testApp);
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 } : {}) };
+ const variants = packageJson.sentryTest?.[VARIANT_FIELDS[variant]] || [];
+ const baseInclude = { 'test-application': testApp };
if (shouldSkip) {
return;
}
- // Add the basic test-application itself, if it is in the current mode
- if (optionalMode === isOptional) {
- includes.push(baseInclude);
+ // The test application itself is either required or optional, never canary, and only runs in the matrix of its mode.
+ const appVariant = isOptional ? 'optional' : 'required';
+ if (appVariant === variant) {
+ includes.push({ ...baseInclude, ...packageJson.sentryTest?.baseVariant });
}
variants.forEach(variant => {
diff --git a/dev-packages/e2e-tests/package.json b/dev-packages/e2e-tests/package.json
index d2ef227b500d..8e25d9e26997 100644
--- a/dev-packages/e2e-tests/package.json
+++ b/dev-packages/e2e-tests/package.json
@@ -12,14 +12,14 @@
"test:validate": "node validate-packed-tarball-setup.mjs",
"clean": "rimraf tmp node_modules packed && pnpm run clean:test-applications && pnpm run clean:pnpm",
"ci:build-matrix": "node ./lib/getTestMatrix.mjs",
- "ci:build-matrix-optional": "node ./lib/getTestMatrix.mjs --optional=true",
+ "ci:build-matrix-optional": "node ./lib/getTestMatrix.mjs --variant=optional",
+ "ci:build-matrix-canary": "node ./lib/getTestMatrix.mjs --variant=canary",
"ci:copy-to-temp": "node ./ciCopyToTemp.mjs",
"ci:pnpm-overrides": "node ./ciPnpmOverrides.mjs",
"clean:test-applications": "rimraf --glob test-applications/**/{node_modules,dist,build,.next,.nuxt,.sveltekit,.react-router,.astro,.output,pnpm-lock.yaml,.last-run.json,test-results,.angular}",
"clean:pnpm": "pnpm store prune"
},
"devDependencies": {
- "eslint-plugin-regexp": "^3.1.0",
"rimraf": "^6.1.3"
}
}
diff --git a/dev-packages/e2e-tests/run.mjs b/dev-packages/e2e-tests/run.mjs
index d8ed9865b2f8..15c739388456 100644
--- a/dev-packages/e2e-tests/run.mjs
+++ b/dev-packages/e2e-tests/run.mjs
@@ -19,7 +19,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
* @typedef {{ 'build-command': string; 'assert-command'?: string; label?: string; skip?: boolean; runtime?: string }} SentryTestVariant
*/
-/** @typedef {{ sentryTest?: { variants?: SentryTestVariant[]; optionalVariants?: SentryTestVariant[] } }} PackageJson */
+/** @typedef {{ sentryTest?: { baseVariant?: SentryTestVariant; variants?: SentryTestVariant[]; optionalVariants?: SentryTestVariant[]; canaryVariants?: SentryTestVariant[] } }} PackageJson */
const DEFAULT_DSN = 'https://username@domain/123';
const DEFAULT_SENTRY_ORG_SLUG = 'sentry-javascript-sdks';
@@ -98,8 +98,10 @@ async function getVariantBuildCommand(packageJsonPath, variantLabel, testAppPath
const packageJson = JSON.parse(packageJsonContent);
const allVariants = [
+ ...(packageJson.sentryTest?.baseVariant ? [packageJson.sentryTest.baseVariant] : []),
...(packageJson.sentryTest?.variants || []),
...(packageJson.sentryTest?.optionalVariants || []),
+ ...(packageJson.sentryTest?.canaryVariants || []),
];
const matchingVariant = findMatchingVariant(allVariants, variantLabel);
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 c4d95a31e2cf..1c0a004af2fe 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.29.0",
+ "@sentry/conventions": "0.30.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",
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 60e53772f091..ce311f876192 100644
--- a/dev-packages/e2e-tests/test-applications/angular-21/package.json
+++ b/dev-packages/e2e-tests/test-applications/angular-21/package.json
@@ -47,10 +47,10 @@
"typescript": "~6.0.0"
},
"sentryTest": {
- "optionalVariants": [
+ "canaryVariants": [
{
"build-command": "pnpm test:build-canary",
- "label": "angular (canary)"
+ "label": "angular-21 (canary)"
}
]
}
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 a66c7252ef21..7384f9368932 100644
--- a/dev-packages/e2e-tests/test-applications/angular-22/package.json
+++ b/dev-packages/e2e-tests/test-applications/angular-22/package.json
@@ -47,10 +47,10 @@
"typescript": "~6.0.0"
},
"sentryTest": {
- "optionalVariants": [
+ "canaryVariants": [
{
"build-command": "pnpm test:build-canary",
- "label": "angular (canary)"
+ "label": "angular-22 (canary)"
}
]
}
diff --git a/dev-packages/e2e-tests/test-applications/astro-4/astro.config.mjs b/dev-packages/e2e-tests/test-applications/astro-4/astro.config.mjs
index 890d1f737e9a..d0848b0f6d00 100644
--- a/dev-packages/e2e-tests/test-applications/astro-4/astro.config.mjs
+++ b/dev-packages/e2e-tests/test-applications/astro-4/astro.config.mjs
@@ -1,5 +1,5 @@
import node from '@astrojs/node';
-import sentry from '@sentry/astro';
+import sentry from '@sentry/astro/integration';
import { defineConfig } from 'astro/config';
import spotlightjs from '@spotlightjs/astro';
diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/astro.config.mjs b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/astro.config.mjs
index 0473ef424d83..a3687388202b 100644
--- a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/astro.config.mjs
+++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/astro.config.mjs
@@ -1,5 +1,5 @@
import cloudflare from '@astrojs/cloudflare';
-import sentry from '@sentry/astro';
+import sentry from '@sentry/astro/integration';
// @ts-check
import { defineConfig } from 'astro/config';
diff --git a/dev-packages/e2e-tests/test-applications/astro-5/astro.config.mjs b/dev-packages/e2e-tests/test-applications/astro-5/astro.config.mjs
index 061db869d94b..54d1c2bbb8cd 100644
--- a/dev-packages/e2e-tests/test-applications/astro-5/astro.config.mjs
+++ b/dev-packages/e2e-tests/test-applications/astro-5/astro.config.mjs
@@ -1,4 +1,4 @@
-import sentry from '@sentry/astro';
+import sentry from '@sentry/astro/integration';
// @ts-check
import { defineConfig } from 'astro/config';
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 4a09ea6bd88e..617cd784f967 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
@@ -1,4 +1,4 @@
-import sentry from '@sentry/astro';
+import sentry from '@sentry/astro/integration';
// @ts-check
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
diff --git a/dev-packages/e2e-tests/test-applications/astro-6/astro.config.mjs b/dev-packages/e2e-tests/test-applications/astro-6/astro.config.mjs
index 061db869d94b..54d1c2bbb8cd 100644
--- a/dev-packages/e2e-tests/test-applications/astro-6/astro.config.mjs
+++ b/dev-packages/e2e-tests/test-applications/astro-6/astro.config.mjs
@@ -1,4 +1,4 @@
-import sentry from '@sentry/astro';
+import sentry from '@sentry/astro/integration';
// @ts-check
import { defineConfig } from 'astro/config';
diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/astro.config.mjs b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/astro.config.mjs
index 3b859eeb1c75..53887471a483 100644
--- a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/astro.config.mjs
+++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/astro.config.mjs
@@ -1,5 +1,5 @@
import node from '@astrojs/node';
-import sentry from '@sentry/astro';
+import sentry from '@sentry/astro/integration';
import { defineConfig } from 'astro/config';
import * as fs from 'node:fs';
diff --git a/dev-packages/e2e-tests/test-applications/astro-7-static/astro.config.mjs b/dev-packages/e2e-tests/test-applications/astro-7-static/astro.config.mjs
index 061db869d94b..54d1c2bbb8cd 100644
--- a/dev-packages/e2e-tests/test-applications/astro-7-static/astro.config.mjs
+++ b/dev-packages/e2e-tests/test-applications/astro-7-static/astro.config.mjs
@@ -1,4 +1,4 @@
-import sentry from '@sentry/astro';
+import sentry from '@sentry/astro/integration';
// @ts-check
import { defineConfig } from 'astro/config';
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 49370ed1e18b..7392e48b5cb7 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
@@ -1,4 +1,4 @@
-import sentry from '@sentry/astro';
+import sentry from '@sentry/astro/integration';
// @ts-check
import { defineConfig } from 'astro/config';
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 eb082877e055..d6f7553e52e5 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
@@ -21,7 +21,6 @@
"rimraf": "^5.0.10"
},
"sentryTest": {
- "runner": "namespace-profile-sentry-javascript",
"variants": [
{
"build-command": "NODE_VERSION=22 pnpm test:build",
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 6ea89590f8c5..17d525126209 100644
--- a/dev-packages/e2e-tests/test-applications/browser-bfcache/package.json
+++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/package.json
@@ -20,8 +20,5 @@
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz",
"typescript": "~5.8.3",
"vite": "^7.3.2"
- },
- "sentryTest": {
- "runner": "namespace-profile-sentry-javascript"
}
}
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 4d537a600017..5989cce49a5f 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,7 +22,7 @@
"@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.29.0",
+ "@sentry/conventions": "0.30.0",
"typescript": "^5.5.2",
"vite": "7.3.5",
"wrangler": "^4.136.2"
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 f32066af21d8..78012b914707 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,7 +19,7 @@
"@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",
+ "@sentry/conventions": "0.30.0",
"@types/node": "^26.1.2",
"sentry": "~0.44.1",
"typescript": "~6.0.3",
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 9858e5c45875..f5541c6be21c 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,5 +39,13 @@
"last 1 firefox version",
"last 1 safari version"
]
+ },
+ "sentryTest": {
+ "canaryVariants": [
+ {
+ "build-command": "pnpm test:build-canary",
+ "label": "create-react-app (canary)"
+ }
+ ]
}
}
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 35f5df09e3e3..3d0ff3e11d38 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
@@ -1,6 +1,6 @@
{
"name": "gen-ai-libraries",
- "description": "Real gen_ai spans for every instrumented AI library (OpenAI, Anthropic, Mistral, Together, Vercel AI), each driven through OpenRouter with a chat query and a tool call, on Node and on Cloudflare",
+ "description": "Real gen_ai spans for every instrumented AI library (OpenAI, Anthropic, Mistral, Together, Vercel AI), each driven through OpenRouter with a chat query and a tool call, plus streamed OpenAI and Anthropic chats, on Node and on Cloudflare",
"version": "1.0.0",
"private": true,
"type": "module",
@@ -13,7 +13,8 @@
"test:build": "pnpm install",
"test:build:cloudflare": "pnpm install && vite build",
"test:assert": "pnpm test",
- "test:assert:cloudflare": "RUNTIME=cloudflare pnpm test"
+ "test:assert:cloudflare": "RUNTIME=cloudflare pnpm test",
+ "test:build-latest": "pnpm install && pnpm add openai@latest together-ai@latest ai@latest @openrouter/ai-sdk-provider@latest '@anthropic-ai/sdk@<1' @mistralai/mistralai@latest"
},
"dependencies": {
"@anthropic-ai/sdk": "0.63.0",
@@ -32,6 +33,7 @@
"@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.30.0",
"@types/express": "^4.17.21",
"@types/node": "^18.19.1",
"tsx": "4.21.0",
@@ -46,6 +48,10 @@
"build-command": "pnpm test:build:cloudflare",
"assert-command": "pnpm test:assert:cloudflare",
"label": "gen-ai-libraries (cloudflare)"
+ },
+ {
+ "build-command": "pnpm test:build-latest",
+ "label": "gen-ai-libraries (latest)"
}
]
}
diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/entry.cloudflare.ts b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/entry.cloudflare.ts
index 265b8122f0df..10ddd8d13a03 100644
--- a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/entry.cloudflare.ts
+++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/entry.cloudflare.ts
@@ -1,10 +1,24 @@
// The Cloudflare variant: the same libraries and routes as the Node entry, but instrumented by the
// `@sentry/cloudflare/vite` bundler plugin (build-time channel injection) and run on workerd.
import * as Sentry from '@sentry/cloudflare';
+import type { Library } from './libraries';
import { libraries } from './libraries';
const byId = new Map(libraries.map(library => [library.id, library]));
+function getCall(library: Library, action: string | undefined): ((apiKey: string) => Promise) | undefined {
+ switch (action) {
+ case 'chat':
+ return library.chat;
+ case 'tools':
+ return library.tools;
+ case 'chat-stream':
+ return library.chatStream;
+ default:
+ return undefined;
+ }
+}
+
export default Sentry.withSentry(
(env: Env) => ({
dsn: env.E2E_TEST_DSN,
@@ -17,8 +31,9 @@ export default Sentry.withSentry(
const url = new URL(request.url);
const [, id, action] = url.pathname.split('/');
const library = id ? byId.get(id) : undefined;
+ const call = library && getCall(library, action);
- if (!library || (action !== 'chat' && action !== 'tools')) {
+ if (!call) {
return new Response('Not found', { status: 404 });
}
@@ -29,7 +44,7 @@ export default Sentry.withSentry(
try {
const spanName = action === 'tools' ? 'ai-tool-workflow' : 'ai-workflow';
- const result = await Sentry.startSpan({ name: spanName, op: 'function' }, () => library[action](apiKey));
+ const result = await Sentry.startSpan({ name: spanName, op: 'function' }, () => call(apiKey));
return Response.json({ result });
} catch (error) {
return Response.json({ message: (error as Error).message }, { status: 500 });
diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/entry.node.ts b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/entry.node.ts
index 726924dbd69d..95a405dfa4d0 100644
--- a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/entry.node.ts
+++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/entry.node.ts
@@ -33,6 +33,18 @@ for (const library of libraries) {
next(error);
}
});
+
+ const chatStream = library.chatStream;
+ if (chatStream) {
+ app.get(`/${library.id}/chat-stream`, async (_req, res, next) => {
+ try {
+ const answer = await Sentry.startSpan({ name: 'ai-workflow', op: 'function' }, () => chatStream(apiKey));
+ res.send({ answer });
+ } catch (error) {
+ next(error);
+ }
+ });
+ }
}
Sentry.setupExpressErrorHandler(app);
diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/libraries.ts b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/libraries.ts
index 1b1490e7e9b4..f8497fce7a33 100644
--- a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/libraries.ts
+++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/libraries.ts
@@ -21,6 +21,7 @@ import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { Mistral } from '@mistralai/mistralai';
import OpenAI from 'openai';
import Together from 'together-ai';
+import { VERSION as TOGETHER_VERSION } from 'together-ai/version';
import { generateText, tool } from 'ai';
import { z } from 'zod';
@@ -60,6 +61,51 @@ export interface Library {
origin?: string;
chat: (apiKey: string) => Promise;
tools: (apiKey: string) => Promise;
+ /** A streamed chat query; only set for the libraries whose streaming is tested. */
+ chatStream?: (apiKey: string) => Promise;
+}
+
+const openAiClient = (apiKey: string): OpenAI => new OpenAI({ apiKey, baseURL: OPENROUTER_V1 });
+
+// OpenRouter's Anthropic skin authenticates with a bearer token, so the key goes in `authToken`
+// (Authorization: Bearer) rather than `apiKey` (x-api-key).
+const anthropicClient = (apiKey: string): Anthropic =>
+ new Anthropic({ authToken: apiKey, baseURL: OPENROUTER_BASE, fetch: fetchWithoutDone });
+
+/**
+ * OpenRouter ends each stream with an `event: data` / `data: [DONE]` frame, which is not part of
+ * Anthropic's protocol. Since 0.136.0 the SDK JSON-parses every named event, so this frame
+ * makes it throw. Drop the frame before the SDK sees it.
+ */
+async function fetchWithoutDone(input: string | URL | Request, init?: RequestInit): Promise {
+ const response = await fetch(input, init);
+ if (!response.body || !response.headers.get('content-type')?.includes('text/event-stream')) {
+ return response;
+ }
+
+ let buffer = '';
+ const keepFrame = (frame: string): boolean => !/^data: \[DONE\]$/m.test(frame);
+ const body = response.body.pipeThrough(new TextDecoderStream()).pipeThrough(
+ new TransformStream({
+ transform(chunk, controller) {
+ buffer += chunk;
+ const frames = buffer.split(/(?<=\r?\n\r?\n)/);
+ buffer = /\r?\n\r?\n$/.test(frames[frames.length - 1] ?? '') ? '' : (frames.pop() ?? '');
+ controller.enqueue(frames.filter(keepFrame).join(''));
+ },
+ flush(controller) {
+ if (keepFrame(buffer)) {
+ controller.enqueue(buffer);
+ }
+ },
+ }),
+ );
+
+ return new Response(body.pipeThrough(new TextEncoderStream()), {
+ status: response.status,
+ statusText: response.statusText,
+ headers: response.headers,
+ });
}
/** Chat + forced tool call for an OpenAI-compatible chat-completions client (OpenAI, Together). */
@@ -100,12 +146,35 @@ function openAiCompatible(
}
export const libraries: Library[] = [
- openAiCompatible('openai', 'openai', 'auto.ai.openai', apiKey => new OpenAI({ apiKey, baseURL: OPENROUTER_V1 })),
+ {
+ ...openAiCompatible('openai', 'openai', 'auto.ai.openai', openAiClient),
+ chatStream: async apiKey => {
+ const stream = await openAiClient(apiKey).chat.completions.create({
+ model: MODEL,
+ messages: [
+ { role: 'system', content: SYSTEM },
+ { role: 'user', content: CHAT_PROMPT },
+ ],
+ temperature: 0,
+ max_tokens: 32,
+ stream: true,
+ // Ask for the usage in the final chunk, so the streamed span carries token counts.
+ stream_options: { include_usage: true },
+ });
+ let answer = '';
+ for await (const chunk of stream) {
+ answer += chunk.choices[0]?.delta.content ?? '';
+ }
+ return answer;
+ },
+ },
openAiCompatible(
'together',
'together_ai',
'auto.ai.together_ai',
- apiKey => new Together({ apiKey, baseURL: OPENROUTER_V1 }) as any,
+ // together-ai 1.0 moved `/v1` from its base URL into the request paths.
+ apiKey =>
+ new Together({ apiKey, baseURL: TOGETHER_VERSION.startsWith('0.') ? OPENROUTER_V1 : OPENROUTER_BASE }) as any,
),
{
@@ -146,10 +215,7 @@ export const libraries: Library[] = [
provider: 'anthropic',
origin: 'auto.ai.anthropic',
chat: async apiKey => {
- // OpenRouter's Anthropic skin authenticates with a bearer token, so the key goes in `authToken`
- // (Authorization: Bearer) rather than `apiKey` (x-api-key).
- const client = new Anthropic({ authToken: apiKey, baseURL: OPENROUTER_BASE });
- const message = await client.messages.create({
+ const message = await anthropicClient(apiKey).messages.create({
model: MODEL,
max_tokens: 32,
temperature: 0,
@@ -160,8 +226,7 @@ export const libraries: Library[] = [
return first && first.type === 'text' ? first.text : '';
},
tools: async apiKey => {
- const client = new Anthropic({ authToken: apiKey, baseURL: OPENROUTER_BASE });
- const message = await client.messages.create({
+ const message = await anthropicClient(apiKey).messages.create({
model: MODEL,
max_tokens: 64,
messages: [{ role: 'user', content: WEATHER_PROMPT }],
@@ -180,6 +245,22 @@ export const libraries: Library[] = [
});
return (message.content ?? []).filter(block => block.type === 'tool_use');
},
+ chatStream: async apiKey => {
+ const stream = await anthropicClient(apiKey).messages.create({
+ model: MODEL,
+ max_tokens: 32,
+ system: SYSTEM,
+ messages: [{ role: 'user', content: CHAT_PROMPT }],
+ stream: true,
+ });
+ let answer = '';
+ for await (const event of stream) {
+ if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
+ answer += event.delta.text;
+ }
+ }
+ return answer;
+ },
},
{
diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/chat-stream.test.ts b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/chat-stream.test.ts
new file mode 100644
index 000000000000..2a68b3f6e5f4
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/chat-stream.test.ts
@@ -0,0 +1,59 @@
+import { expect, test } from '@playwright/test';
+import {
+ GEN_AI_PROVIDER_NAME,
+ GEN_AI_RESPONSE_FINISH_REASONS,
+ GEN_AI_RESPONSE_STREAMING,
+ GEN_AI_USAGE_OUTPUT_TOKENS,
+ SENTRY_ORIGIN,
+} from '@sentry/conventions/attributes';
+import type { SerializedStreamedSpan } from '@sentry-internal/test-utils';
+import { collectStreamedSpans } from '@sentry-internal/test-utils';
+import { APP } from './constants';
+import type { LibraryUnderTest } from './utils';
+import { attr, describeTree, expectCommonChatAttributes, isModelCallSpan, LIBRARIES } from './utils';
+
+// Streaming is where OpenRouter's real wire format can diverge from the mocked responses in
+// node-integration-tests, so it is only covered here.
+
+const OPENAI = LIBRARIES.find(library => library.id === 'openai')!;
+const ANTHROPIC = LIBRARIES.find(library => library.id === 'anthropic')!;
+
+const isStreamedModelCallSpan = (span: SerializedStreamedSpan, library: LibraryUnderTest): boolean =>
+ isModelCallSpan(span, library) && attr(span, GEN_AI_RESPONSE_STREAMING) === true;
+
+async function requestChatStream(
+ baseURL: string | undefined,
+ library: LibraryUnderTest,
+): Promise {
+ const spansPromise = collectStreamedSpans(APP, spansOfTrace =>
+ spansOfTrace.some(span => isStreamedModelCallSpan(span, library)),
+ );
+
+ const response = await fetch(`${baseURL}/${library.id}/chat-stream`);
+ expect(response.status, await response.text()).toBe(200);
+
+ const spans = await spansPromise;
+ const modelSpan = spans.find(span => isStreamedModelCallSpan(span, library));
+ expect(modelSpan, `expected a streamed ${library.op} span in:\n${describeTree(spans)}`).toBeDefined();
+ return modelSpan!;
+}
+
+test('openai: a streamed chat completion records its token usage', async ({ baseURL }) => {
+ const span = await requestChatStream(baseURL, OPENAI);
+
+ // The usage only arrives in the final chunk, which the streaming instrumentation has to pick up.
+ expectCommonChatAttributes(span, OPENAI);
+ expect(attr(span, GEN_AI_RESPONSE_FINISH_REASONS)).toContain('stop');
+});
+
+test('anthropic: a streamed message records its output usage', async ({ baseURL }) => {
+ const span = await requestChatStream(baseURL, ANTHROPIC);
+
+ // Not `expectCommonChatAttributes`: the instrumentation takes the input usage from `message_start`,
+ // where OpenRouter reports 0, and ignores the real count in `message_delta`.
+ expect(span.status).toBe('ok');
+ expect(attr(span, GEN_AI_USAGE_OUTPUT_TOKENS)).toBeGreaterThan(0);
+ expect(attr(span, GEN_AI_RESPONSE_FINISH_REASONS)).toContain('end_turn');
+ expect(attr(span, GEN_AI_PROVIDER_NAME)).toBe(ANTHROPIC.provider);
+ expect(attr(span, SENTRY_ORIGIN)).toBe(ANTHROPIC.origin);
+});
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 008be0636b19..baf473e3471e 100644
--- a/dev-packages/e2e-tests/test-applications/hono-4/package.json
+++ b/dev-packages/e2e-tests/test-applications/hono-4/package.json
@@ -8,14 +8,16 @@
"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')",
+ "dev:cloudflare:vite": "CLOUDFLARE_INCLUDE_PROCESS_ENV=true vite dev",
"test:build": "pnpm install && pnpm typecheck",
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
"test": "TEST_ENV=production playwright test",
+ "test:dev": "TEST_ENV=development playwright test",
"test:assert": "pnpm test:assert:node",
"test:assert:node": "RUNTIME=node pnpm test",
"test:assert:bun": "bun run build-bun.ts && RUNTIME=bun pnpm test",
"test:assert:deno": "RUNTIME=deno pnpm test",
- "test:assert:cloudflare": "vite build && RUNTIME=cloudflare pnpm test"
+ "test:assert:cloudflare": "vite build && RUNTIME=cloudflare pnpm test && RUNTIME=cloudflare pnpm test:dev"
},
"dependencies": {
"@sentry/bun": "latest || *",
diff --git a/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts b/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts
index 475e082970af..5b40a1bd48a0 100644
--- a/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts
+++ b/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts
@@ -10,7 +10,11 @@ if (!testEnv) {
const APP_PORT = 8787;
const startCommands: Record = {
- cloudflare: `pnpm dev:cloudflare --port ${APP_PORT}`,
+ // `vite dev` pre-bundles `hono` with its Rolldown dependency optimizer, so this also tests the transform there.
+ cloudflare:
+ testEnv === 'development'
+ ? `pnpm dev:cloudflare:vite --port ${APP_PORT} --strictPort`
+ : `pnpm dev:cloudflare --port ${APP_PORT}`,
node: `pnpm dev:node`,
bun: `pnpm dev:bun`,
deno: `pnpm dev:deno`,
diff --git a/dev-packages/e2e-tests/test-applications/lighthouse-react/index.html b/dev-packages/e2e-tests/test-applications/lighthouse-react/index.html
deleted file mode 100644
index dbddfb003021..000000000000
--- a/dev-packages/e2e-tests/test-applications/lighthouse-react/index.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
- Lighthouse Fixture
-
-
-
-
-
-
diff --git a/dev-packages/e2e-tests/test-applications/lighthouse-react/package.json b/dev-packages/e2e-tests/test-applications/lighthouse-react/package.json
deleted file mode 100644
index b0a9d2858d57..000000000000
--- a/dev-packages/e2e-tests/test-applications/lighthouse-react/package.json
+++ /dev/null
@@ -1,37 +0,0 @@
-{
- "name": "lighthouse-react-test-app",
- "private": true,
- "type": "module",
- "sentryTest": {
- "skip": true
- },
- "scripts": {
- "build": "vite build",
- "build:no-sentry": "vite build --mode no-sentry",
- "build:init-only": "vite build --mode init-only",
- "build:errors-only": "vite build --mode errors-only",
- "build:minimal-integrations": "vite build --mode minimal-integrations",
- "build:no-integrations": "vite build --mode no-integrations",
- "build:no-browser-api-errors": "vite build --mode no-browser-api-errors",
- "build:no-breadcrumbs": "vite build --mode no-breadcrumbs",
- "build:no-browser-session": "vite build --mode no-browser-session",
- "build:tracing": "vite build --mode tracing",
- "build:tracing-lazy-import": "vite build --mode tracing-lazy-import",
- "build:tracing-replay": "vite build --mode tracing-replay",
- "preview": "vite preview",
- "clean": "npx rimraf node_modules pnpm-lock.yaml dist",
- "test:build": "pnpm install && pnpm build"
- },
- "dependencies": {
- "@sentry/react": "file:../../packed/sentry-react-packed.tgz",
- "react": "^18.3.1",
- "react-dom": "^18.3.1"
- },
- "devDependencies": {
- "@types/react": "^18.3.0",
- "@types/react-dom": "^18.3.0",
- "@vitejs/plugin-react-swc": "^3.7.0",
- "typescript": "~5.4.0",
- "vite": "^5.4.0"
- }
-}
diff --git a/dev-packages/e2e-tests/test-applications/lighthouse-react/public/logo.svg b/dev-packages/e2e-tests/test-applications/lighthouse-react/public/logo.svg
deleted file mode 100644
index a1ee6ba8d243..000000000000
--- a/dev-packages/e2e-tests/test-applications/lighthouse-react/public/logo.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
diff --git a/dev-packages/e2e-tests/test-applications/lighthouse-react/src/App.tsx b/dev-packages/e2e-tests/test-applications/lighthouse-react/src/App.tsx
deleted file mode 100644
index bf6e99e7419d..000000000000
--- a/dev-packages/e2e-tests/test-applications/lighthouse-react/src/App.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import { useEffect, useState } from 'react';
-
-const CARDS = [
- { title: 'Performance', body: 'Measure and optimize your app render times and interaction latency.' },
- { title: 'Accessibility', body: 'Ensure your interface is usable by everyone, including assistive technologies.' },
- { title: 'Best Practices', body: 'Follow modern web development patterns for secure and maintainable code.' },
- { title: 'SEO', body: 'Optimize discoverability with semantic markup and structured metadata.' },
- { title: 'PWA', body: 'Add offline support and installability via service workers and manifests.' },
- { title: 'Security', body: 'Protect users with CSP headers, HTTPS, and input validation.' },
-];
-
-export default function App() {
- const [count, setCount] = useState(0);
-
- useEffect(() => {
- document.title = `Lighthouse Fixture (${count})`;
- }, [count]);
-
- return (
-
-
-
-
-
- Lighthouse Fixture
-
- This app exists to measure JavaScript bundle size and runtime cost across three Sentry instrumentation
- configurations. Each build mode ships a different level of SDK integration.
-
-
-
-
- {CARDS.map(card => (
-
- {card.title}
- {card.body}
-
- ))}
-
-
-
-
- );
-}
diff --git a/dev-packages/e2e-tests/test-applications/lighthouse-react/src/main.tsx b/dev-packages/e2e-tests/test-applications/lighthouse-react/src/main.tsx
deleted file mode 100644
index 352c53c7208b..000000000000
--- a/dev-packages/e2e-tests/test-applications/lighthouse-react/src/main.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-import * as Sentry from '@sentry/react';
-import { createRoot } from 'react-dom/client';
-import App from './App';
-
-const sentryInitStart = performance.now();
-
-performance.measure('sentry-sdk-pre-init-duration', {
- detail: { mode: import.meta.env.MODE ?? 'unknown_mode' },
- start: 0,
- end: sentryInitStart,
-});
-
-performance.mark('sentry-sdk-init-start', {
- detail: { mode: import.meta.env.MODE ?? 'unknown_mode' },
-});
-
-if (import.meta.env.MODE === 'tracing-replay') {
- Sentry.init({
- dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined,
- release: 'lighthouse-fixture',
- environment: 'qa',
- integrations: [Sentry.browserTracingIntegration(), Sentry.replayIntegration()],
- tracesSampleRate: 1.0,
- replaysSessionSampleRate: 1.0,
- replaysOnErrorSampleRate: 1.0,
- });
-} else if (import.meta.env.MODE === 'tracing') {
- // Tracing + errors, but no replay — isolates the replay integration's cost.
- Sentry.init({
- dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined,
- release: 'lighthouse-fixture',
- environment: 'qa',
- integrations: [Sentry.browserTracingIntegration()],
- tracesSampleRate: 1.0,
- });
-} else if (import.meta.env.MODE === 'tracing-lazy-import') {
- // Tracing + errors, but browsertracing loaded lazily.
- // (We don't recommend this setup anywhere and neither will it work well.
- // this is purely for testing if it changes anything about overhead.)
- Sentry.init({
- dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined,
- release: 'lighthouse-fixture',
- environment: 'qa',
- tracesSampleRate: 1.0,
- });
-
- import('@sentry/react').then(lazySentry => {
- Sentry.addIntegration(lazySentry.browserTracingIntegration());
- });
-} else if (import.meta.env.MODE === 'errors-only') {
- // Default integrations only — errors are always captured, no tracing or replay.
- Sentry.init({
- dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined,
- release: 'lighthouse-fixture',
- environment: 'qa',
- });
-} else if (import.meta.env.MODE === 'minimal-integrations') {
- // Minimal integratoins setup only (everything necessary to automatically get errors)
- Sentry.init({
- dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined,
- release: 'lighthouse-fixture',
- environment: 'qa',
- defaultIntegrations: false,
- integrations: [
- Sentry.globalHandlersIntegration(),
- Sentry.linkedErrorsIntegration(),
- Sentry.dedupeIntegration(),
- // for good measure, let's include event filters since noise reduction is usually desired
- // 99% of this integration's work is done in an event processor, so outside the hot path
- Sentry.eventFiltersIntegration(),
- ],
- });
-} else if (import.meta.env.MODE === 'no-integrations') {
- // DSN set but every integration disabled. Isolates the cost of the enabled
- // client itself from the default instrumentation that wraps DOM/timer/network APIs.
- Sentry.init({
- dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined,
- release: 'lighthouse-fixture',
- environment: 'qa',
- defaultIntegrations: false,
- integrations: [],
- });
-} else if (import.meta.env.MODE === 'no-browser-api-errors') {
- // Default integrations minus BrowserApiErrors, which wraps addEventListener/
- // removeEventListener on ~32 prototypes plus setTimeout/setInterval/rAF/XHR.
- // Isolates that global monkey-patching cost from the rest of the defaults.
- Sentry.init({
- dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined,
- release: 'lighthouse-fixture',
- environment: 'qa',
- integrations: defaultIntegrations =>
- defaultIntegrations.filter(integration => integration.name !== 'BrowserApiErrors'),
- });
-} else if (import.meta.env.MODE === 'no-breadcrumbs') {
- // Default integrations minus Breadcrumbs, which adds a lot of monkey patching to
- // DOM and Network APIs as well as event targets and listeners
- Sentry.init({
- dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined,
- release: 'lighthouse-fixture',
- environment: 'qa',
- integrations: defaultIntegrations => defaultIntegrations.filter(integration => integration.name !== 'Breadcrumbs'),
- });
-} else if (import.meta.env.MODE === 'no-browser-session') {
- // Default integrations minus Breadcrumbs, which adds a lot of monkey patching to
- // DOM and Network APIs as well as event targets and listeners
- Sentry.init({
- dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined,
- release: 'lighthouse-fixture',
- environment: 'qa',
- integrations: defaultIntegrations =>
- defaultIntegrations.filter(integration => integration.name !== 'BrowserSession'),
- });
-} else if (import.meta.env.MODE === 'init-only') {
- // enabled: false makes the SDK a guaranteed no-op (no transport allocation,
- // no DSN warning). We're measuring pure SDK-loading + tree-shaking cost.
- Sentry.init({ enabled: false });
-}
-
-performance.measure('sentry-sdk-init-duration', {
- detail: { mode: import.meta.env.MODE ?? 'unknown_mode' },
- start: sentryInitStart,
- end: performance.now(),
-});
-performance.mark('sentry-sdk-init-end', {
- detail: { mode: import.meta.env.MODE ?? 'unknown_mode' },
-});
-
-// 'no-sentry' mode: all branches above are statically dead, so Vite drops
-// the @sentry/react import entirely from the bundle.
-
-// Elements tagged with `elementtiming` (e.g. the hero logo) emit a
-// PerformanceElementTiming entry when painted. Re-emit each as a
-// performance.measure spanning time origin -> render so the element's render
-// duration lands on the same timeline as the SDK init marks.
-// `PerformanceElementTiming` isn't in this project's TS lib, so type it locally.
-type ElementTimingEntry = PerformanceEntry & {
- identifier: string;
- renderTime: number;
- loadTime: number;
-};
-
-const elementTimingObserver = new PerformanceObserver(list => {
- for (const entry of list.getEntries() as ElementTimingEntry[]) {
- const renderEnd = entry.renderTime || entry.loadTime;
-
- performance.measure(`element-timing-${entry.identifier}`, {
- detail: { mode: import.meta.env.MODE ?? 'unknown_mode', identifier: entry.identifier },
- start: 0,
- end: renderEnd,
- });
- }
-});
-
-elementTimingObserver.observe({ type: 'element', buffered: true });
-
-const root = createRoot(document.getElementById('root')!);
-root.render( );
diff --git a/dev-packages/e2e-tests/test-applications/lighthouse-react/tsconfig.json b/dev-packages/e2e-tests/test-applications/lighthouse-react/tsconfig.json
deleted file mode 100644
index 3934b8f6d676..000000000000
--- a/dev-packages/e2e-tests/test-applications/lighthouse-react/tsconfig.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
- "compilerOptions": {
- "target": "ES2020",
- "useDefineForClassFields": true,
- "lib": ["ES2020", "DOM", "DOM.Iterable"],
- "module": "ESNext",
- "skipLibCheck": true,
- "moduleResolution": "bundler",
- "allowImportingTsExtensions": true,
- "resolveJsonModule": true,
- "isolatedModules": true,
- "noEmit": true,
- "jsx": "react-jsx",
- "strict": true,
- "noUnusedLocals": true,
- "noUnusedParameters": true,
- "noFallthroughCasesInSwitch": true
- },
- "include": ["src"],
- "references": [{ "path": "./tsconfig.node.json" }]
-}
diff --git a/dev-packages/e2e-tests/test-applications/lighthouse-react/tsconfig.node.json b/dev-packages/e2e-tests/test-applications/lighthouse-react/tsconfig.node.json
deleted file mode 100644
index 42872c59f5b0..000000000000
--- a/dev-packages/e2e-tests/test-applications/lighthouse-react/tsconfig.node.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "compilerOptions": {
- "composite": true,
- "skipLibCheck": true,
- "module": "ESNext",
- "moduleResolution": "bundler",
- "allowSyntheticDefaultImports": true
- },
- "include": ["vite.config.ts"]
-}
diff --git a/dev-packages/e2e-tests/test-applications/lighthouse-react/vite.config.ts b/dev-packages/e2e-tests/test-applications/lighthouse-react/vite.config.ts
deleted file mode 100644
index 4f312acb1a84..000000000000
--- a/dev-packages/e2e-tests/test-applications/lighthouse-react/vite.config.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { defineConfig } from 'vite';
-import react from '@vitejs/plugin-react-swc';
-
-export default defineConfig({
- plugins: [react()],
-});
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 724a30b63c0f..db3903bfd091 100644
--- a/dev-packages/e2e-tests/test-applications/nestjs-12/package.json
+++ b/dev-packages/e2e-tests/test-applications/nestjs-12/package.json
@@ -42,5 +42,13 @@
"ts-loader": "^9.4.3",
"tsconfig-paths": "^4.2.0",
"typescript": "~5.9.0"
+ },
+ "sentryTest": {
+ "optionalVariants": [
+ {
+ "build-command": "pnpm test:build-latest",
+ "label": "nestjs-12 (latest)"
+ }
+ ]
}
}
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.controller.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.controller.ts
index 9ffc328a2c30..5a30cf575da8 100644
--- a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.controller.ts
+++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.controller.ts
@@ -86,6 +86,18 @@ export class AppController {
return { result: await this.appService.testSpanDecoratorSync() };
}
+ @Get('test-derived-cron')
+ async testDerivedCron() {
+ await this.appService.testDerivedCron();
+ return {};
+ }
+
+ @Get('test-auto-monitored-cron')
+ async testAutoMonitoredCron() {
+ await this.appService.testAutoMonitoredCron();
+ return {};
+ }
+
@Get('kill-test-cron/:job')
async killTestCron(@Param('job') job: string) {
this.appService.killTestCron(job);
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.service.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.service.ts
index a7b91b7b3d98..712f6777ccf5 100644
--- a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.service.ts
+++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.service.ts
@@ -113,6 +113,19 @@ export class AppService {
throw new Error('Test error from cron sync job');
}
+ // Disabled so it only runs through the `test-derived-cron` endpoint, without waiting for the schedule.
+ @Cron('0 30 9 * * 1-5', { name: 'test-derived-cron', timeZone: 'Europe/Vienna', disabled: true })
+ @SentryCron('test-derived-cron-slug', { checkinMargin: 2 })
+ async testDerivedCron() {
+ console.log('Test derived cron!');
+ }
+
+ // Monitored through `monitorCronJobs`, without `@SentryCron`.
+ @Cron('0 0 * * *', { name: 'test-auto-monitored-cron', timeZone: 'America/New_York', disabled: true })
+ async testAutoMonitoredCron() {
+ console.log('Test auto-monitored cron!');
+ }
+
async killTestCron(job: string) {
this.schedulerRegistry.deleteCronJob(job);
}
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/instrument.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/instrument.ts
index 07454fc8d8c8..7b07f60a3252 100644
--- a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/instrument.ts
+++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/instrument.ts
@@ -5,6 +5,7 @@ Sentry.init({
dsn: process.env.E2E_TEST_DSN,
tunnel: `http://localhost:3031/`, // proxy server
tracesSampleRate: 1,
+ integrations: [Sentry.nestIntegration({ monitorCronJobs: true })],
transportOptions: {
// We expect the app to send a lot of events in a short time
bufferSize: 1000,
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/cron-decorator.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/cron-decorator.test.ts
index 6aeeae723a64..2302bd6bdb9c 100644
--- a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/cron-decorator.test.ts
+++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/cron-decorator.test.ts
@@ -62,6 +62,58 @@ test('Cron job triggers send of in_progress envelope', async ({ baseURL }) => {
await fetch(`${baseURL}/kill-test-cron/test-cron-job`);
});
+test('Sends the schedule and time zone of @Cron with the check-in', async ({ baseURL }) => {
+ const inProgressEnvelopePromise = waitForEnvelopeItem('nestjs-basic', envelope => {
+ return (
+ envelope[0].type === 'check_in' &&
+ envelope[1]['monitor_slug'] === 'test-derived-cron-slug' &&
+ envelope[1]['status'] === 'in_progress'
+ );
+ });
+
+ await fetch(`${baseURL}/test-derived-cron`);
+
+ const inProgressEnvelope = await inProgressEnvelopePromise;
+
+ expect(inProgressEnvelope[1]).toEqual(
+ expect.objectContaining({
+ monitor_slug: 'test-derived-cron-slug',
+ status: 'in_progress',
+ monitor_config: {
+ schedule: {
+ type: 'crontab',
+ value: '30 9 * * 1-5',
+ },
+ timezone: 'Europe/Vienna',
+ checkin_margin: 2,
+ },
+ }),
+ );
+});
+
+test('Monitors @Cron methods without @SentryCron when monitorCronJobs is enabled', async ({ baseURL }) => {
+ const inProgressEnvelopePromise = waitForEnvelopeItem('nestjs-basic', envelope => {
+ return (
+ envelope[0].type === 'check_in' &&
+ envelope[1]['monitor_slug'] === 'test-auto-monitored-cron' &&
+ envelope[1]['status'] === 'in_progress'
+ );
+ });
+
+ await fetch(`${baseURL}/test-auto-monitored-cron`);
+
+ expect((await inProgressEnvelopePromise)[1]).toEqual(
+ expect.objectContaining({
+ monitor_slug: 'test-auto-monitored-cron',
+ status: 'in_progress',
+ monitor_config: {
+ schedule: { type: 'crontab', value: '0 0 * * *' },
+ timezone: 'America/New_York',
+ },
+ }),
+ );
+});
+
test('Sends exceptions to Sentry on error in async cron job', async ({ baseURL }) => {
const errorEventPromise = waitForError('nestjs-basic', event => {
return (
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 9a3aa45dc92b..2704a2f90c0b 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-14/package.json
+++ b/dev-packages/e2e-tests/test-applications/nextjs-14/package.json
@@ -29,14 +29,16 @@
},
"sentryTest": {
"optionalVariants": [
- {
- "build-command": "pnpm test:build-canary",
- "label": "nextjs-14 (canary)"
- },
{
"build-command": "pnpm test:build-latest",
"label": "nextjs-14 (latest)"
}
+ ],
+ "canaryVariants": [
+ {
+ "build-command": "pnpm test:build-canary",
+ "label": "nextjs-14 (canary)"
+ }
]
}
}
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 576cd662335d..998bd43f1e1d 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
@@ -23,8 +23,5 @@
"devDependencies": {
"@playwright/test": "1.64.1",
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz"
- },
- "sentryTest": {
- "runner": "namespace-profile-sentry-javascript"
}
}
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 59d044708b93..78014972b88e 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
@@ -24,8 +24,5 @@
"devDependencies": {
"@playwright/test": "1.64.1",
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz"
- },
- "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 115787f8fbbd..5529013fa11e 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
@@ -43,8 +43,5 @@
},
"ct3aMetadata": {
"initVersion": "7.47.1"
- },
- "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 fb1cad71c57e..237b2b221229 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json
@@ -31,7 +31,6 @@
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz"
},
"sentryTest": {
- "runner": "namespace-profile-sentry-javascript",
"optionalVariants": [
{
"build-command": "pnpm test:build-latest",
@@ -42,6 +41,18 @@
"assert-command": "pnpm test:prod-turbo && pnpm test:dev-turbo",
"label": "nextjs-15 (turbo)"
}
+ ],
+ "baseVariant": {
+ "build-command": "pnpm test:build",
+ "label": "nextjs-15 (production)",
+ "assert-command": "pnpm test:prod"
+ },
+ "variants": [
+ {
+ "build-command": "pnpm install",
+ "label": "nextjs-15 (development)",
+ "assert-command": "pnpm test:dev"
+ }
]
}
}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/.gitignore
new file mode 100644
index 000000000000..dd146b53d966
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/.gitignore
@@ -0,0 +1,44 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files (can opt-in for committing if needed)
+.env*
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
+
+# Sentry Config File
+.env.sentry-build-plugin
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/adapter.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/adapter.mjs
new file mode 100644
index 000000000000..26bf16a619bc
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/adapter.mjs
@@ -0,0 +1,16 @@
+import { writeFile } from 'node:fs/promises';
+import { join } from 'node:path';
+
+// Records the built proxy entrypoint so `server.mjs` can invoke it separately from the render, like Vercel does.
+/** @type {import('next').NextAdapter} */
+const adapter = {
+ name: 'sentry-e2e-adapter',
+ async onBuildComplete({ outputs, distDir }) {
+ await writeFile(
+ join(distDir, 'e2e-adapter-outputs.json'),
+ JSON.stringify({ middleware: outputs.middleware?.filePath, distDir }),
+ );
+ },
+};
+
+export default adapter;
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/app/layout.tsx
new file mode 100644
index 000000000000..c8f9cee0b787
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/app/layout.tsx
@@ -0,0 +1,7 @@
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/app/page.tsx
new file mode 100644
index 000000000000..b8115846ad25
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/app/page.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return Next.js 16 Adapter Test
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/instrumentation.ts
new file mode 100644
index 000000000000..897effa19c8b
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/instrumentation.ts
@@ -0,0 +1,9 @@
+import * as Sentry from '@sentry/nextjs';
+
+export async function register() {
+ if (process.env.NEXT_RUNTIME === 'nodejs') {
+ await import('./sentry.server.config');
+ }
+}
+
+export const onRequestError = Sentry.captureRequestError;
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/next.config.ts
new file mode 100644
index 000000000000..660eddbbfe1f
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/next.config.ts
@@ -0,0 +1,11 @@
+import { withSentryConfig } from '@sentry/nextjs/config';
+import type { NextConfig } from 'next';
+
+const nextConfig: NextConfig = {
+ adapterPath: require.resolve('./adapter.mjs'),
+};
+
+export default withSentryConfig(nextConfig, {
+ silent: true,
+ tunnelRoute: '/monitoring',
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/package.json
new file mode 100644
index 000000000000..f26999360e44
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "nextjs-16-adapter",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)",
+ "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs",
+ "start": "node server.mjs",
+ "test:prod": "TEST_ENV=production playwright test",
+ "test:build": "pnpm install && pnpm build",
+ "test:build-latest": "pnpm install && pnpm add next@latest && pnpm build",
+ "test:assert": "pnpm test:prod"
+ },
+ "dependencies": {
+ "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz",
+ "next": "16.4.5",
+ "react": "19.1.0",
+ "react-dom": "19.1.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "1.64.1",
+ "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz",
+ "@sentry/conventions": "0.30.0",
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "typescript": "^5"
+ },
+ "sentryTest": {
+ "runner": "namespace-profile-sentry-javascript",
+ "variants": [
+ {
+ "build-command": "pnpm test:build",
+ "label": "nextjs-16-adapter (turbopack)",
+ "assert-command": "pnpm test:assert"
+ },
+ {
+ "build-command": "pnpm test:build-latest",
+ "label": "nextjs-16-adapter (latest, turbopack)",
+ "assert-command": "pnpm test:assert"
+ }
+ ]
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/playwright.config.mjs
new file mode 100644
index 000000000000..7fc1df2e92de
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/playwright.config.mjs
@@ -0,0 +1,12 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+
+if (process.env.TEST_ENV !== 'production') {
+ throw new Error(`Unknown test env: ${process.env.TEST_ENV}`);
+}
+
+const config = getPlaywrightConfig({
+ startCommand: 'pnpm start',
+ port: 3030,
+});
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/lighthouse-react/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/pnpm-workspace.yaml
similarity index 56%
rename from dev-packages/e2e-tests/test-applications/lighthouse-react/pnpm-workspace.yaml
rename to dev-packages/e2e-tests/test-applications/nextjs-16-adapter/pnpm-workspace.yaml
index dafa2e7eca8c..0aaed65978c9 100644
--- a/dev-packages/e2e-tests/test-applications/lighthouse-react/pnpm-workspace.yaml
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/pnpm-workspace.yaml
@@ -1,4 +1,5 @@
allowBuilds:
- '@swc/core': true
esbuild: true
+ unrs-resolver: true
+ workerd: true
minimumReleaseAge: 0
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/proxy.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/proxy.ts
new file mode 100644
index 000000000000..b5cda0cfaa92
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/proxy.ts
@@ -0,0 +1,24 @@
+import * as Sentry from '@sentry/nextjs';
+import { NextResponse } from 'next/server';
+import type { NextRequest } from 'next/server';
+
+export async function proxy(request: NextRequest) {
+ if (request.nextUrl.pathname !== '/api/proxy-isolation') {
+ return NextResponse.next();
+ }
+
+ const requestId = request.nextUrl.searchParams.get('id');
+
+ Sentry.addBreadcrumb({ message: `proxy breadcrumb ${requestId}` });
+
+ // Lets a concurrent request add its breadcrumb before this one captures its error
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ Sentry.captureException(new Error(`Proxy error ${requestId}`));
+
+ return NextResponse.next();
+}
+
+export const config = {
+ matcher: ['/api/proxy-isolation', '/api/proxy-ok', '/monitoring'],
+};
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/sentry.server.config.ts
new file mode 100644
index 000000000000..ad780407a5b7
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/sentry.server.config.ts
@@ -0,0 +1,8 @@
+import * as Sentry from '@sentry/nextjs';
+
+Sentry.init({
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
+ tunnel: `http://localhost:3031/`, // proxy server
+ tracesSampleRate: 1.0,
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/server.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/server.mjs
new file mode 100644
index 000000000000..074bd5908a5e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/server.mjs
@@ -0,0 +1,26 @@
+import { readFileSync } from 'node:fs';
+import { createServer } from 'node:http';
+import { createRequire } from 'node:module';
+import { join } from 'node:path';
+
+// Invokes the proxy entrypoint directly, without next-server, like platforms that run the proxy separately from the
+// render (e.g. Vercel). Without incoming trace context, Next.js >= 16.4.3 then starts a `Next.request` root span.
+const { middleware: middlewarePath, distDir } = JSON.parse(
+ readFileSync(join(process.cwd(), '.next', 'e2e-adapter-outputs.json'), 'utf8'),
+);
+const { handler } = createRequire(import.meta.url)(middlewarePath);
+
+createServer(async (req, res) => {
+ try {
+ const response = await handler(new Request(`http://localhost:3030${req.url}`, { headers: req.headers }), {
+ waitUntil: () => {},
+ requestMeta: { relativeProjectDir: '.', distDir },
+ });
+ res.writeHead(response.status, { 'x-middleware-next': response.headers.get('x-middleware-next') ?? '' });
+ res.end(await response.text());
+ } catch (error) {
+ console.error(error);
+ res.writeHead(500);
+ res.end();
+ }
+}).listen(3030);
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/start-event-proxy.mjs
new file mode 100644
index 000000000000..9dc760924cfe
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/start-event-proxy.mjs
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'nextjs-16-adapter',
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tests/proxy-isolation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tests/proxy-isolation.test.ts
new file mode 100644
index 000000000000..ef35acd77869
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tests/proxy-isolation.test.ts
@@ -0,0 +1,34 @@
+import { expect, test } from '@playwright/test';
+import { waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils';
+
+function getProxyBreadcrumbs(event: { breadcrumbs?: { message?: string }[] }): (string | undefined)[] {
+ return (event.breadcrumbs ?? [])
+ .map(breadcrumb => breadcrumb.message)
+ .filter(message => message?.startsWith('proxy breadcrumb'));
+}
+
+test('isolates scope data of concurrent proxy requests under the `Next.request` root span', async ({ request }) => {
+ const rootSpanPromise = waitForStreamedSpan('nextjs-16-adapter', span => {
+ return span.is_segment && span.attributes['next.span_type']?.value === 'Next.request';
+ });
+ const errorAPromise = waitForError('nextjs-16-adapter', event => {
+ return event.exception?.values?.[0]?.value === 'Proxy error a';
+ });
+ const errorBPromise = waitForError('nextjs-16-adapter', event => {
+ return event.exception?.values?.[0]?.value === 'Proxy error b';
+ });
+
+ const responses = await Promise.all([
+ request.get('/api/proxy-isolation?id=a'),
+ request.get('/api/proxy-isolation?id=b'),
+ ]);
+ for (const response of responses) {
+ expect(response.headers()['x-middleware-next']).toBe('1');
+ }
+
+ await rootSpanPromise;
+
+ const [errorA, errorB] = await Promise.all([errorAPromise, errorBPromise]);
+ expect(getProxyBreadcrumbs(errorA)).toEqual(['proxy breadcrumb a']);
+ expect(getProxyBreadcrumbs(errorB)).toEqual(['proxy breadcrumb b']);
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tests/proxy-tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tests/proxy-tunnel.test.ts
new file mode 100644
index 000000000000..4c8807745ea0
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tests/proxy-tunnel.test.ts
@@ -0,0 +1,30 @@
+import { expect, test } from '@playwright/test';
+import { HTTP_TARGET } from '@sentry/conventions/attributes';
+import { waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils';
+
+test('drops the `Next.request` root span of tunnel requests through the proxy', async ({ request }) => {
+ // The callback never returns true, so this promise is deliberately left unsettled. The assertions read the array.
+ const segmentSpans: Awaited>[] = [];
+ void waitForStreamedSpans('nextjs-16-adapter', spans => {
+ segmentSpans.push(...spans.filter(span => span.is_segment));
+ return false;
+ });
+
+ const proxyOkSpanPromise = waitForStreamedSpan('nextjs-16-adapter', span => {
+ return (
+ span.is_segment &&
+ span.attributes['next.span_type']?.value === 'Next.request' &&
+ span.attributes[HTTP_TARGET]?.value === '/api/proxy-ok'
+ );
+ });
+
+ const tunnelResponse = await request.post('/monitoring?o=123&p=456', { data: '{}' });
+ expect(tunnelResponse.headers()['x-middleware-next']).toBe('1');
+
+ await request.get('/api/proxy-ok');
+ await proxyOkSpanPromise;
+
+ expect(segmentSpans.filter(span => String(span.attributes[HTTP_TARGET]?.value).startsWith('/monitoring'))).toEqual(
+ [],
+ );
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tsconfig.json
new file mode 100644
index 000000000000..cc9ed39b5aa2
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"],
+ "exclude": ["node_modules"]
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/.gitignore
index dd146b53d966..e0fc7baa1ebc 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/.gitignore
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/.gitignore
@@ -42,3 +42,6 @@ next-env.d.ts
# Sentry Config File
.env.sentry-build-plugin
+
+# OpenNext build output
+/.open-next/
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/prefetch-overlap/[id]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/prefetch-overlap/[id]/page.tsx
new file mode 100644
index 000000000000..0e176752416b
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/prefetch-overlap/[id]/page.tsx
@@ -0,0 +1,14 @@
+import { Suspense } from 'react';
+
+export default function Page({ params }: { params: Promise<{ id: string }> }) {
+ return (
+ Loading...
}>
+
+
+ );
+}
+
+async function Result({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ return {`prefetched ${id}`}
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/runtime-shell/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/runtime-shell/page.tsx
new file mode 100644
index 000000000000..1cf4a1dd4663
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/runtime-shell/page.tsx
@@ -0,0 +1,27 @@
+import { cacheLife } from 'next/cache';
+import { headers } from 'next/headers';
+import { Suspense } from 'react';
+
+// The shell goes stale after a second, so a later visit makes the server prerender it again at
+// runtime. An `expire` below 5 minutes would make Next.js leave this out of the shell.
+async function RenderedAt() {
+ 'use cache';
+ cacheLife({ revalidate: 1, expire: 3600 });
+ return {Date.now()}
;
+}
+
+async function RequestContent() {
+ await headers();
+ return Rendered for this request
;
+}
+
+export default function Page() {
+ return (
+ <>
+
+ Loading...}>
+
+
+ >
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/slow-hole/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/slow-hole/page.tsx
new file mode 100644
index 000000000000..201e4102cb0d
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/slow-hole/page.tsx
@@ -0,0 +1,24 @@
+import { headers } from 'next/headers';
+import Link from 'next/link';
+import { Suspense } from 'react';
+
+// The dynamic part, and with it the trace meta tag, streams in seconds after the shell, so a test
+// can navigate while the pageload still waits for the tag.
+async function SlowContent() {
+ await headers();
+ await new Promise(resolve => setTimeout(resolve, 4000));
+ return Slow content
;
+}
+
+export default function Page() {
+ return (
+ <>
+
+ Static shell
+
+ Loading...}>
+
+
+ >
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/open-next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/open-next.config.ts
new file mode 100644
index 000000000000..fa6cb73ebf69
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/open-next.config.ts
@@ -0,0 +1,3 @@
+import { defineCloudflareConfig } from '@opennextjs/cloudflare';
+
+export default defineCloudflareConfig({});
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 eba44d444994..6174845c517d 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
@@ -20,9 +20,12 @@
"test:build-latest-webpack": "pnpm install && pnpm add next@latest && pnpm build-webpack",
"test:build-canary-webpack": "pnpm install && pnpm add next@canary && pnpm build-webpack",
"test:assert": "pnpm test:prod && pnpm test:dev",
- "test:assert-webpack": "TEST_BUNDLER=webpack pnpm test:prod && pnpm test:dev-webpack"
+ "test:assert-webpack": "TEST_BUNDLER=webpack pnpm test:prod && pnpm test:dev-webpack",
+ "test:build:cloudflare": "pnpm install && opennextjs-cloudflare build",
+ "start:cloudflare": "opennextjs-cloudflare preview --var \"E2E_TEST_DSN:$E2E_TEST_DSN\""
},
"dependencies": {
+ "@opennextjs/cloudflare": "^1.20.12",
"@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz",
"@sentry/core": "file:../../packed/sentry-core-packed.tgz",
"import-in-the-middle": "^1",
@@ -35,21 +38,30 @@
"devDependencies": {
"@playwright/test": "1.64.1",
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz",
+ "@sentry/conventions": "0.27.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "^16",
- "typescript": "^5"
+ "typescript": "^5",
+ "wrangler": "^4.148.0"
},
"sentryTest": {
- "runner": "namespace-profile-sentry-javascript",
"variants": [
{
"build-command": "pnpm test:build-webpack",
"label": "nextjs-16-cacheComponents (webpack)",
"assert-command": "pnpm test:assert-webpack"
}
+ ],
+ "optionalVariants": [
+ {
+ "runtime": "cloudflare",
+ "build-command": "pnpm test:build:cloudflare",
+ "assert-command": "pnpm test:prod",
+ "label": "nextjs-16-cacheComponents (cloudflare)"
+ }
]
}
}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/playwright.config.mjs
index 797418b8cf7d..43c2eb7e575e 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/playwright.config.mjs
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/playwright.config.mjs
@@ -1,10 +1,15 @@
-import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+import { getPlaywrightConfig, getRuntime } from '@sentry-internal/test-utils';
const testEnv = process.env.TEST_ENV;
if (!testEnv) {
throw new Error('No test env defined');
}
+const productionStartCommands = {
+ node: 'pnpm next start -p 3030',
+ cloudflare: 'pnpm start:cloudflare --port 3030',
+};
+
const getStartCommand = () => {
if (testEnv === 'development-webpack') {
return 'pnpm next dev -p 3030 --webpack 2>&1 | tee .tmp_dev_server_logs';
@@ -15,7 +20,7 @@ const getStartCommand = () => {
}
if (testEnv === 'production') {
- return 'pnpm next start -p 3030';
+ return productionStartCommands[getRuntime()];
}
throw new Error(`Unknown test env: ${testEnv}`);
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/pnpm-workspace.yaml
index bde9dfb7b138..0aaed65978c9 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/pnpm-workspace.yaml
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/pnpm-workspace.yaml
@@ -1,3 +1,5 @@
allowBuilds:
+ esbuild: true
unrs-resolver: true
+ workerd: true
minimumReleaseAge: 0
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts
index 900ce43679e9..f06c4da961cd 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts
@@ -1,5 +1,6 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
+import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes';
import { waitForTransaction } from '@sentry-internal/test-utils';
const APP = 'nextjs-16-cacheComponents';
@@ -7,7 +8,8 @@ const APP = 'nextjs-16-cacheComponents';
// The server only writes `sentry-trace` meta tags for a render that happened for the current request,
// never while it prerenders a shell. So a tag in the document can always be continued; the only
// question is when it arrives. The resumed part of a prerendered shell arrives after the SDK started,
-// which the pageload instrumentation waits for.
+// which the pageload instrumentation waits for. A shell prerendered at runtime carries a
+// `sentry-cache-origin` tag instead, which marks the pageload as static and links the prerender.
test('connects the pageload trace to the runtime server request', async ({ page }) => {
// The shell is prerendered, but the page's dynamic part (`DynamicContent` awaits `headers()`)
@@ -30,7 +32,6 @@ test('connects the pageload trace to the runtime server request', async ({ page
expect(serverTx.contexts?.trace?.trace_id).toBeTruthy();
expect(pageloadTx.contexts?.trace?.trace_id).toBe(serverTx.contexts?.trace?.trace_id);
- expect(pageloadTx.contexts?.trace?.links).toBeUndefined();
});
test('connects the pageload trace to a blocking render', async ({ page }) => {
@@ -58,10 +59,11 @@ test('does not share a trace between visitors of a shell generated at request ti
test.skip(process.env.TEST_ENV !== 'production', 'Shells are only cached in production builds');
// `[id]` only lists `test` in `generateStaticParams`, so the first visit prerenders and caches the
- // shell for this id. The prerender writes no trace tag, so every visitor starts a fresh trace.
+ // shell for this id. The prerender writes no trace tag, only a `sentry-cache-origin` tag with its own
+ // trace, so every visitor starts a fresh trace that links the prerender and is marked as static.
const id = `shell-${Date.now()}`;
- const visit = async (): Promise<{ traceId: string | undefined; links: unknown }> => {
+ const visit = async (): Promise<{ traceId: string | undefined; links: unknown; serverRenderingType: unknown }> => {
const context = await browser.newContext();
const page: Page = await context.newPage();
@@ -75,11 +77,16 @@ test('does not share a trace between visitors of a shell generated at request ti
await page.goto(`/${id}`);
await expect(page.locator('#result')).toHaveText(`Hello, ${id}!`);
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);
+ expect(await page.locator('meta[name="sentry-cache-origin"]').count()).toBe(1);
const pageloadTx = await pageloadTxPromise;
await context.close();
- return { traceId: pageloadTx.contexts?.trace?.trace_id, links: pageloadTx.contexts?.trace?.links };
+ return {
+ traceId: pageloadTx.contexts?.trace?.trace_id,
+ links: pageloadTx.contexts?.trace?.links,
+ serverRenderingType: pageloadTx.contexts?.trace?.data?.['page.server_rendering.type'],
+ };
};
const first = await visit();
@@ -88,8 +95,73 @@ test('does not share a trace between visitors of a shell generated at request ti
expect(first.traceId).toBeTruthy();
expect(second.traceId).toBeTruthy();
expect(second.traceId).not.toBe(first.traceId);
- expect(first.links).toBeUndefined();
- expect(second.links).toBeUndefined();
+ expect(first.serverRenderingType).toBe('static');
+ expect(second.serverRenderingType).toBe('static');
+
+ // Both link the request that prerendered the shell, which is the first visitor's own request. The
+ // client cannot tell the first visit from later ones, so that pageload starts a fresh trace too.
+ const linkOf = (links: unknown): { trace_id: string; attributes?: Record } => {
+ const link = (links as Array<{ trace_id: string; attributes?: Record }>)[0];
+ expect(link?.attributes?.[SENTRY_LINK_TYPE]).toBe('cache_origin');
+ return link!;
+ };
+ expect(linkOf(first.links).trace_id).toBe(linkOf(second.links).trace_id);
+ expect(linkOf(second.links).trace_id).not.toBe(second.traceId);
+});
+
+test('continues the request trace on a shell prerendered at runtime and links the prerender', async ({
+ browser,
+ page,
+ baseURL,
+}) => {
+ test.skip(process.env.TEST_ENV !== 'production', 'Shells are only cached in production builds');
+
+ // The shell from the build carries no `sentry-cache-origin` tag. Wait until a visit serves the
+ // shell that the server prerendered again at runtime.
+ const warmup = await browser.newPage();
+ await warmup.goto('/runtime-shell');
+ const renderedAtBuild = await warmup.locator('#rendered-at').textContent();
+ await expect
+ .poll(
+ async () => {
+ await warmup.waitForTimeout(1500);
+ await warmup.goto('/runtime-shell');
+ return warmup.locator('#rendered-at').textContent();
+ },
+ { timeout: 30_000 },
+ )
+ .not.toBe(renderedAtBuild);
+ await warmup.close();
+
+ const pageloadTxPromise = waitForTransaction(APP, transactionEvent => {
+ return (
+ transactionEvent.contexts?.trace?.op === 'pageload' &&
+ transactionEvent.contexts?.trace?.data?.['url.full'] === `${baseURL}/runtime-shell?visit=last`
+ );
+ });
+
+ await page.goto('/runtime-shell?visit=last');
+ await expect(page.locator('#request-content')).toHaveText('Rendered for this request');
+ const [requestTraceId] = (await page.locator('meta[name="sentry-trace"]').getAttribute('content'))!.split('-');
+ const [prerenderTraceId, prerenderSpanId] = (await page
+ .locator('meta[name="sentry-cache-origin"]')
+ .getAttribute('content'))!.split('-');
+
+ const pageloadTx = await pageloadTxPromise;
+
+ // Only the shell came from the cache. The rest of the page rendered for this request, and the
+ // pageload continues that trace.
+ expect(pageloadTx.contexts?.trace?.trace_id).toBe(requestTraceId);
+ expect(pageloadTx.contexts?.trace?.data?.['page.server_rendering.type']).toBe('partially_static');
+ expect(pageloadTx.contexts?.trace?.links).toEqual([
+ {
+ trace_id: prerenderTraceId,
+ span_id: prerenderSpanId,
+ sampled: true,
+ attributes: { [SENTRY_LINK_TYPE]: 'cache_origin' },
+ },
+ ]);
+ expect(prerenderTraceId).not.toBe(requestTraceId);
});
test('starts a fresh trace on a fully static page', async ({ page }) => {
@@ -105,8 +177,38 @@ test('starts a fresh trace on a fully static page', async ({ page }) => {
const pageloadTx = await pageloadTxPromise;
- // A build-time shell carries no trace context at all.
+ // A build-time shell carries no trace context and no marker, the SDK does not run during the build.
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);
+ expect(await page.locator('meta[name="sentry-cache-origin"]').count()).toBe(0);
expect(pageloadTx.contexts?.trace?.trace_id).toBeTruthy();
expect(pageloadTx.contexts?.trace?.links).toBeUndefined();
+ expect(pageloadTx.contexts?.trace?.data?.['page.server_rendering.type']).toBeUndefined();
+});
+
+test('keeps a navigation that starts while the pageload waits for the trace meta tag', async ({ page }) => {
+ test.skip(process.env.TEST_ENV !== 'production', 'Only a prerendered shell makes the trace meta tag arrive late');
+
+ const pageloadTxPromise = waitForTransaction(APP, transactionEvent => {
+ return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/slow-hole';
+ });
+
+ const navigationTxPromise = waitForTransaction(APP, transactionEvent => {
+ return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/static-shell';
+ });
+
+ await page.goto('/slow-hole', { waitUntil: 'commit' });
+ await page.waitForFunction(() => !!(window as { next?: { router?: unknown } }).next?.router);
+
+ // The resumed part, which carries the tag, has not arrived yet, so the pageload is still waiting.
+ await expect(page.locator('#slow-fallback')).toBeVisible();
+ expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);
+
+ await page.locator('#navigate').click();
+ await expect(page.locator('#static-shell')).toHaveText('Static shell');
+
+ const [pageloadTx, navigationTx] = await Promise.all([pageloadTxPromise, navigationTxPromise]);
+
+ // The navigation ends the pageload like any pageload it interrupts, and runs to its own idle timeout.
+ expect(pageloadTx.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('cancelled');
+ expect(navigationTx.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout');
});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/prefetchOverlap.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/prefetchOverlap.spec.ts
new file mode 100644
index 000000000000..e5666a4f909e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/prefetchOverlap.spec.ts
@@ -0,0 +1,21 @@
+import { expect, test } from '@playwright/test';
+
+const REQUEST_COUNT = 12;
+
+// Next.js aborts a Cache Components prerender when randomness is generated while it runs. On Cloudflare Workers the
+// SDK used to generate it inside the prerender of another request once prefetches overlapped, which turned their
+// responses into the one byte partial marker `~` (https://github.com/getsentry/sentry-javascript/issues/25245).
+test('overlapping runtime prefetch requests render their pages', async ({ request }) => {
+ // `Next-Router-Prefetch: 2` is what the Next.js router sends for a runtime prefetch, which Next.js answers with a
+ // prerender at request time.
+ const responses = await Promise.all(
+ Array.from({ length: REQUEST_COUNT }, (_, index) =>
+ request.get(`/prefetch-overlap/overlap-${index + 1}`, { headers: { RSC: '1', 'Next-Router-Prefetch': '2' } }),
+ ),
+ );
+
+ for (const [index, response] of responses.entries()) {
+ expect(response.ok()).toBe(true);
+ expect(await response.text()).toContain(`prefetched overlap-${index + 1}`);
+ }
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/worker.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/worker.ts
new file mode 100644
index 000000000000..44cb2e195eea
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/worker.ts
@@ -0,0 +1,19 @@
+import * as Sentry from '@sentry/nextjs/cloudflare';
+// @ts-ignore the file exists only after `opennextjs-cloudflare build`
+import handler from './.open-next/worker.js';
+
+// @ts-ignore the file exists only after `opennextjs-cloudflare build`
+export { DOQueueHandler, DOShardedTagCache, BucketCachePurge } from './.open-next/worker.js';
+
+export default Sentry.withSentry(
+ (env: { E2E_TEST_DSN: string }) => ({
+ // The specs of this app assert transaction envelopes, like `sentry.server.config.ts`. On Workers the request
+ // transaction belongs to this client, so with span streaming here they would never see one.
+ traceLifecycle: 'static',
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ dsn: env.E2E_TEST_DSN,
+ tunnel: `http://localhost:3031/`, // proxy server
+ tracesSampleRate: 1.0,
+ }),
+ handler,
+);
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/wrangler.jsonc
new file mode 100644
index 000000000000..ec500c50130d
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/wrangler.jsonc
@@ -0,0 +1,21 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "nextjs-16-cachecomponents",
+ "main": "./worker.ts",
+ // From 2025-06-16 on, workerd binds an `AsyncLocalStorage` snapshot to the request that took it, which
+ // tests/prefetchOverlap.spec.ts relies on.
+ "compatibility_date": "2026-02-19",
+ "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
+ "assets": {
+ "binding": "ASSETS",
+ "directory": ".open-next/assets",
+ },
+ "services": [
+ {
+ // Self-reference service binding, the service name must match the worker name
+ // see https://opennext.js.org/cloudflare/caching
+ "binding": "WORKER_SELF_REFERENCE",
+ "service": "nextjs-16-cachecomponents",
+ },
+ ],
+}
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 df76d8284b31..4ef0a0350d4b 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
@@ -49,7 +49,7 @@
"assert-command": "pnpm test:assert"
}
],
- "optionalVariants": [
+ "canaryVariants": [
{
"build-command": "pnpm test:build-canary",
"label": "nextjs-16-standalone (canary, turbopack)",
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 a8a2366e2445..062c709ae23d 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
@@ -41,6 +41,17 @@
"typescript": "^5"
},
"sentryTest": {
- "runner": "namespace-profile-sentry-javascript"
+ "baseVariant": {
+ "build-command": "pnpm test:build",
+ "label": "nextjs-16-static (production)",
+ "assert-command": "pnpm test:prod"
+ },
+ "variants": [
+ {
+ "build-command": "pnpm install",
+ "label": "nextjs-16-static (development)",
+ "assert-command": "pnpm test:dev"
+ }
+ ]
}
}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/build-output.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/build-output.test.ts
index 735e04649c32..7a6cd16a5c37 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/build-output.test.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/build-output.test.ts
@@ -1,8 +1,11 @@
import { expect, test } from '@playwright/test';
import { findAbsolutePathImports } from '@sentry-internal/test-utils';
import * as path from 'path';
+import { isDevMode } from './isDevMode';
test('emits no absolute-path imports into the server output', () => {
+ test.skip(isDevMode, 'Checks production build output');
+
const leaks = findAbsolutePathImports({ outputDir: path.join(process.cwd(), '.next', 'server') });
expect(leaks).toEqual([]);
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/runtime-shell/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/runtime-shell/page.tsx
new file mode 100644
index 000000000000..1cf4a1dd4663
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/runtime-shell/page.tsx
@@ -0,0 +1,27 @@
+import { cacheLife } from 'next/cache';
+import { headers } from 'next/headers';
+import { Suspense } from 'react';
+
+// The shell goes stale after a second, so a later visit makes the server prerender it again at
+// runtime. An `expire` below 5 minutes would make Next.js leave this out of the shell.
+async function RenderedAt() {
+ 'use cache';
+ cacheLife({ revalidate: 1, expire: 3600 });
+ return {Date.now()}
;
+}
+
+async function RequestContent() {
+ await headers();
+ return Rendered for this request
;
+}
+
+export default function Page() {
+ return (
+ <>
+
+ Loading...}>
+
+
+ >
+ );
+}
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 24c37deeba72..c6ee4a2dca46 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
@@ -35,6 +35,7 @@
"devDependencies": {
"@playwright/test": "1.64.1",
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz",
+ "@sentry/conventions": "0.27.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -43,7 +44,6 @@
"typescript": "^5"
},
"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-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts
index 89f9dc32d26c..3d1512789f82 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts
@@ -1,5 +1,6 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
+import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes';
import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
const APP = 'nextjs-16-streaming-cacheComponents';
@@ -7,7 +8,8 @@ const APP = 'nextjs-16-streaming-cacheComponents';
// The server only writes `sentry-trace` meta tags for a render that happened for the current request,
// never while it prerenders a shell. So a tag in the document can always be continued; the only
// question is when it arrives. The resumed part of a prerendered shell arrives after the SDK started,
-// which the pageload instrumentation waits for.
+// which the pageload instrumentation waits for. A shell prerendered at runtime carries a
+// `sentry-cache-origin` tag instead, which marks the pageload as static and links the prerender.
test('connects the pageload trace to the runtime server request', async ({ page }) => {
// The shell is prerendered, but the page's dynamic part (`DynamicContent` awaits `headers()`)
@@ -28,7 +30,6 @@ test('connects the pageload trace to the runtime server request', async ({ page
expect(serverSpan.trace_id).toBeTruthy();
expect(pageloadSpan.trace_id).toBe(serverSpan.trace_id);
- expect(pageloadSpan.links).toBeUndefined();
});
test('connects the pageload trace to a blocking render', async ({ page }) => {
@@ -56,10 +57,11 @@ test('does not share a trace between visitors of a shell generated at request ti
test.skip(process.env.TEST_ENV !== 'production', 'Shells are only cached in production builds');
// `[id]` only lists `test` in `generateStaticParams`, so the first visit prerenders and caches the
- // shell for this id. The prerender writes no trace tag, so every visitor starts a fresh trace.
+ // shell for this id. The prerender writes no trace tag, only a `sentry-cache-origin` tag with its own
+ // trace, so every visitor starts a fresh trace that links the prerender and is marked as static.
const id = `shell-${Date.now()}`;
- const visit = async (): Promise<{ traceId: string | undefined; links: unknown }> => {
+ const visit = async (): Promise<{ traceId: string | undefined; links: unknown; serverRenderingType: unknown }> => {
const context = await browser.newContext();
const page: Page = await context.newPage();
@@ -70,11 +72,16 @@ test('does not share a trace between visitors of a shell generated at request ti
await page.goto(`/${id}`);
await expect(page.locator('#result')).toHaveText(`Hello, ${id}!`);
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);
+ expect(await page.locator('meta[name="sentry-cache-origin"]').count()).toBe(1);
const pageloadSpan = await pageloadSpanPromise;
await context.close();
- return { traceId: pageloadSpan.trace_id, links: pageloadSpan.links };
+ return {
+ traceId: pageloadSpan.trace_id,
+ links: pageloadSpan.links,
+ serverRenderingType: pageloadSpan.attributes['page.server_rendering.type']?.value,
+ };
};
const first = await visit();
@@ -83,8 +90,74 @@ test('does not share a trace between visitors of a shell generated at request ti
expect(first.traceId).toBeTruthy();
expect(second.traceId).toBeTruthy();
expect(second.traceId).not.toBe(first.traceId);
- expect(first.links).toBeUndefined();
- expect(second.links).toBeUndefined();
+ expect(first.serverRenderingType).toBe('static');
+ expect(second.serverRenderingType).toBe('static');
+
+ // Both link the request that prerendered the shell, which is the first visitor's own request. The
+ // client cannot tell the first visit from later ones, so that pageload starts a fresh trace too.
+ const linkOf = (links: unknown): { trace_id: string; attributes?: Record } => {
+ const link = (links as Array<{ trace_id: string; attributes?: Record }>)[0];
+ expect(link?.attributes?.[SENTRY_LINK_TYPE]?.value).toBe('cache_origin');
+ return link!;
+ };
+ expect(linkOf(first.links).trace_id).toBe(linkOf(second.links).trace_id);
+ expect(linkOf(second.links).trace_id).not.toBe(second.traceId);
+});
+
+test('continues the request trace on a shell prerendered at runtime and links the prerender', async ({
+ browser,
+ page,
+ baseURL,
+}) => {
+ test.skip(process.env.TEST_ENV !== 'production', 'Shells are only cached in production builds');
+
+ // The shell from the build carries no `sentry-cache-origin` tag. Wait until a visit serves the
+ // shell that the server prerendered again at runtime.
+ const warmup = await browser.newPage();
+ await warmup.goto('/runtime-shell');
+ const renderedAtBuild = await warmup.locator('#rendered-at').textContent();
+ await expect
+ .poll(
+ async () => {
+ await warmup.waitForTimeout(1500);
+ await warmup.goto('/runtime-shell');
+ return warmup.locator('#rendered-at').textContent();
+ },
+ { timeout: 30_000 },
+ )
+ .not.toBe(renderedAtBuild);
+ await warmup.close();
+
+ const pageloadSpanPromise = waitForStreamedSpan(APP, span => {
+ return (
+ getSpanOp(span) === 'pageload' &&
+ span.is_segment &&
+ span.attributes['url.full']?.value === `${baseURL}/runtime-shell?visit=last`
+ );
+ });
+
+ await page.goto('/runtime-shell?visit=last');
+ await expect(page.locator('#request-content')).toHaveText('Rendered for this request');
+ const [requestTraceId] = (await page.locator('meta[name="sentry-trace"]').getAttribute('content'))!.split('-');
+ const [prerenderTraceId, prerenderSpanId] = (await page
+ .locator('meta[name="sentry-cache-origin"]')
+ .getAttribute('content'))!.split('-');
+
+ const pageloadSpan = await pageloadSpanPromise;
+
+ // Only the shell came from the cache. The rest of the page rendered for this request, and the
+ // pageload continues that trace.
+ expect(pageloadSpan.trace_id).toBe(requestTraceId);
+ expect(pageloadSpan.attributes['page.server_rendering.type']?.value).toBe('partially_static');
+ expect(pageloadSpan.links).toEqual([
+ {
+ trace_id: prerenderTraceId,
+ span_id: prerenderSpanId,
+ sampled: true,
+ attributes: { [SENTRY_LINK_TYPE]: { type: 'string', value: 'cache_origin' } },
+ },
+ ]);
+ expect(prerenderTraceId).not.toBe(requestTraceId);
});
test('starts a fresh trace on a fully static page', async ({ page }) => {
@@ -100,10 +173,12 @@ test('starts a fresh trace on a fully static page', async ({ page }) => {
const pageloadSpan = await pageloadSpanPromise;
- // A build-time shell carries no trace context at all.
+ // A build-time shell carries no trace context and no marker, the SDK does not run during the build.
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);
+ expect(await page.locator('meta[name="sentry-cache-origin"]').count()).toBe(0);
expect(pageloadSpan.trace_id).toBeTruthy();
expect(pageloadSpan.links).toBeUndefined();
+ expect(pageloadSpan.attributes['page.server_rendering.type']).toBeUndefined();
});
test('keeps a navigation that starts while the pageload waits for the trace meta tag', async ({ page }) => {
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 9870a3f02d64..1dcd01c56f8e 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
@@ -41,7 +41,6 @@
"typescript": "^5"
},
"sentryTest": {
- "runner": "namespace-profile-sentry-javascript",
"variants": [
{
"build-command": "pnpm test:build-webpack",
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 49fab7eb7fe7..cf85a2193e40 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.29.0",
+ "@sentry/conventions": "0.30.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -51,7 +51,6 @@
"wrangler": "^4.61.0"
},
"sentryTest": {
- "runner": "namespace-profile-sentry-javascript",
"variants": [
{
"build-command": "pnpm test:build-webpack",
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/third-party-filter/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/third-party-filter/page.tsx
index b6b4bea80def..434d4ad3cada 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16/app/third-party-filter/page.tsx
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/third-party-filter/page.tsx
@@ -8,17 +8,37 @@ function throwFirstPartyError(): void {
export default function Page() {
return (
- {
- try {
- throwFirstPartyError();
- } catch (e) {
- Sentry.captureException(e);
- }
- }}
- >
- Throw First Party Error
-
+ <>
+ {
+ try {
+ throwFirstPartyError();
+ } catch (e) {
+ Sentry.captureException(e);
+ }
+ }}
+ >
+ Throw First Party Error
+
+ {
+ const { createDependencyError } = await import('sentry-metadata-test-dependency');
+ Sentry.captureException(createDependencyError());
+ }}
+ >
+ Capture Dependency Error
+
+ {
+ const { createAsyncDependencyError } = await import('sentry-metadata-test-dependency');
+ Sentry.captureException(await createAsyncDependencyError());
+ }}
+ >
+ Capture Dependency-Only Error
+
+ >
);
}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/fixtures/metadata-dependency/index.d.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/fixtures/metadata-dependency/index.d.ts
new file mode 100644
index 000000000000..0071fa0b3ec2
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/fixtures/metadata-dependency/index.d.ts
@@ -0,0 +1,2 @@
+export function createDependencyError(): Error;
+export function createAsyncDependencyError(): Promise;
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/fixtures/metadata-dependency/index.js b/dev-packages/e2e-tests/test-applications/nextjs-16/fixtures/metadata-dependency/index.js
new file mode 100644
index 000000000000..be62d583d1d3
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/fixtures/metadata-dependency/index.js
@@ -0,0 +1,10 @@
+export function createDependencyError() {
+ return new Error('application-key-dependency-error');
+}
+
+export function createAsyncDependencyError() {
+ return new Promise(resolve => {
+ // Wait until the application's call stack has unwound so the error stack contains only a dependency frame.
+ queueMicrotask(() => resolve(new Error('application-key-dependency-only-error')));
+ });
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/fixtures/metadata-dependency/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/fixtures/metadata-dependency/package.json
new file mode 100644
index 000000000000..0811417bd955
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/fixtures/metadata-dependency/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "sentry-metadata-test-dependency",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "main": "index.js"
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation-client.ts
index d0f5b13bb555..81b3fe08b4a6 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation-client.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation-client.ts
@@ -6,10 +6,14 @@ Sentry.init({
dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
tunnel: `http://localhost:3031/`, // proxy server
tracesSampleRate: 1.0,
+ _flushInterval: 100,
integrations: [
Sentry.thirdPartyErrorFilterIntegration({
filterKeys: ['nextjs-16-e2e'],
- behaviour: 'apply-tag-if-contains-third-party-frames',
+ behaviour:
+ process.env.NEXT_PUBLIC_EXCLUDE_APPLICATION_KEY_DEPENDENCIES === '1'
+ ? 'apply-tag-if-exclusively-contains-third-party-frames'
+ : 'apply-tag-if-contains-third-party-frames',
}),
],
// Verify Log type is available
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts
index 6893bd77fdf7..aee951174d24 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts
@@ -19,6 +19,7 @@ const nextConfig: NextConfig = {
export default withSentryConfig(nextConfig, {
silent: true,
applicationKey: 'nextjs-16-e2e',
+ applicationKeyExcludeDependencies: process.env.NEXT_PUBLIC_EXCLUDE_APPLICATION_KEY_DEPENDENCIES === '1',
reactComponentAnnotation: {
enabled: true,
},
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 75071f25d027..2da1fec6e7cb 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json
@@ -44,6 +44,7 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"require-in-the-middle": "^8",
+ "sentry-metadata-test-dependency": "file:../../test-applications/nextjs-16/fixtures/metadata-dependency",
"zod": "^3.22.4"
},
"devDependencies": {
@@ -64,21 +65,51 @@
"wrangler": "^4.61.0"
},
"sentryTest": {
- "runner": "namespace-profile-sentry-javascript",
"variants": [
+ {
+ "build-command": "pnpm install",
+ "label": "nextjs-16 (turbopack, development)",
+ "assert-command": "pnpm test:dev"
+ },
+ {
+ "build-command": "NEXT_PUBLIC_EXCLUDE_APPLICATION_KEY_DEPENDENCIES=1 pnpm test:build",
+ "label": "nextjs-16 (exclude application key dependencies, production)",
+ "assert-command": "NEXT_PUBLIC_EXCLUDE_APPLICATION_KEY_DEPENDENCIES=1 pnpm test:prod"
+ },
+ {
+ "build-command": "pnpm install",
+ "label": "nextjs-16 (exclude application key dependencies, development)",
+ "assert-command": "NEXT_PUBLIC_EXCLUDE_APPLICATION_KEY_DEPENDENCIES=1 pnpm test:dev"
+ },
{
"build-command": "pnpm test:build-webpack",
- "label": "nextjs-16 (webpack)",
- "assert-command": "pnpm test:assert-webpack"
+ "label": "nextjs-16 (webpack, production)",
+ "assert-command": "pnpm test:prod"
+ },
+ {
+ "build-command": "pnpm install",
+ "label": "nextjs-16 (webpack, development)",
+ "assert-command": "pnpm test:dev-webpack"
},
{
"build-command": "pnpm test:build-latest-webpack",
- "label": "nextjs-16 (latest, webpack)",
- "assert-command": "pnpm test:assert-webpack"
+ "label": "nextjs-16 (latest, webpack, production)",
+ "assert-command": "pnpm test:prod"
+ },
+ {
+ "build-command": "pnpm install && pnpm add next@latest",
+ "label": "nextjs-16 (latest, webpack, development)",
+ "assert-command": "pnpm test:dev-webpack"
},
{
"build-command": "pnpm test:build-latest",
- "label": "nextjs-16 (latest, turbopack)"
+ "label": "nextjs-16 (latest, turbopack, production)",
+ "assert-command": "pnpm test:prod"
+ },
+ {
+ "build-command": "pnpm install && pnpm add next@latest",
+ "label": "nextjs-16 (latest, turbopack, development)",
+ "assert-command": "pnpm test:dev"
},
{
"runtime": "bun",
@@ -93,16 +124,6 @@
}
],
"optionalVariants": [
- {
- "build-command": "pnpm test:build-canary",
- "label": "nextjs-16 (canary, turbopack)",
- "assert-command": "pnpm test:assert"
- },
- {
- "build-command": "pnpm test:build-canary-webpack",
- "label": "nextjs-16 (canary, webpack)",
- "assert-command": "pnpm test:assert-webpack"
- },
{
"runtime": "cloudflare",
"build-command": "pnpm test:build-latest:cloudflare",
@@ -126,6 +147,23 @@
"assert-command": "pnpm test:assert:vinext",
"label": "nextjs-16 (vinext, bun)"
}
- ]
+ ],
+ "canaryVariants": [
+ {
+ "build-command": "pnpm test:build-canary",
+ "label": "nextjs-16 (canary, turbopack)",
+ "assert-command": "pnpm test:assert"
+ },
+ {
+ "build-command": "pnpm test:build-canary-webpack",
+ "label": "nextjs-16 (canary, webpack)",
+ "assert-command": "pnpm test:assert-webpack"
+ }
+ ],
+ "baseVariant": {
+ "build-command": "pnpm test:build",
+ "label": "nextjs-16 (turbopack, production)",
+ "assert-command": "pnpm test:prod"
+ }
}
}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts
index 960f49a15533..f0ec19dd256d 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts
@@ -8,6 +8,7 @@ Sentry.init({
dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
tunnel: `http://localhost:3031/`, // proxy server
tracesSampleRate: 1.0,
+ _flushInterval: 100,
tracePropagationTargets: ['http://localhost:3030/propagation/test-outgoing-fetch/check'],
// debug: true,
integrations: [
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/build-output.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/build-output.test.ts
index cc3dfde118af..10d41b894524 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/build-output.test.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/build-output.test.ts
@@ -7,7 +7,30 @@ import { isVinext } from './isVinext';
test.skip(isVinext, 'Checks the build output of Next.js');
+test('excludes dependency metadata while preserving application metadata', () => {
+ test.skip(isDevMode || process.env.NEXT_PUBLIC_EXCLUDE_APPLICATION_KEY_DEPENDENCIES !== '1');
+
+ const staticDir = path.join(process.cwd(), '.next', 'static');
+ const chunks = fs
+ .readdirSync(staticDir, { recursive: true })
+ .filter(file => String(file).endsWith('.js'))
+ .map(file => fs.readFileSync(path.join(staticDir, String(file)), 'utf8'));
+ const dependencyChunks = chunks.filter(chunk => chunk.includes('application-key-dependency-error'));
+ const applicationChunks = chunks.filter(chunk => chunk.includes('first-party-error'));
+
+ expect(dependencyChunks.length).toBeGreaterThan(0);
+ expect(applicationChunks.length).toBeGreaterThan(0);
+ for (const chunk of dependencyChunks) {
+ expect(chunk).not.toContain('_sentryBundlerPluginAppKey:nextjs-16-e2e');
+ }
+ for (const chunk of applicationChunks) {
+ expect(chunk).toContain('_sentryBundlerPluginAppKey:nextjs-16-e2e');
+ }
+});
+
test('emits no absolute-path imports into the server output', () => {
+ test.skip(isDevMode, 'Checks production build output');
+
const leaks = findAbsolutePathImports({ outputDir: path.join(process.cwd(), '.next', 'server') });
expect(leaks).toEqual([]);
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts
index 5a210e8426c6..98de20bf60f3 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts
@@ -51,7 +51,7 @@ test.describe('prerendered ISR pages', () => {
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
});
- test('does not carry trace meta tags on an ISR page that was prerendered again at runtime', async ({ page }) => {
+ test('carries only the cache origin on an ISR page that was prerendered again at runtime', async ({ page }) => {
// The first visit serves the shell from the build. Once its revalidation window passed, a visit serves the stale
// shell and makes the server prerender the page again, inside that request, with the SDK running. The visits after
// that serve the shell from that prerender.
@@ -71,6 +71,7 @@ test.describe('prerendered ISR pages', () => {
await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
+ await expect(page.locator('meta[name="sentry-cache-origin"]')).toHaveCount(1);
});
});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/third-party-filter.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/third-party-filter.test.ts
index a89a8cf6645d..2c7104a2864d 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/third-party-filter.test.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/third-party-filter.test.ts
@@ -1,5 +1,6 @@
import test, { expect } from '@playwright/test';
import { waitForError } from '@sentry-internal/test-utils';
+import { isDevMode } from './isDevMode';
import { isVinext } from './isVinext';
const isWebpackDev = process.env.TEST_ENV === 'development-webpack';
@@ -13,6 +14,7 @@ test('First-party error with React frames should not be tagged as third-party co
});
await page.goto('/third-party-filter');
+ await page.waitForFunction(() => '__SENTRY__' in window);
await page.locator('#first-party-error-btn').click();
const errorEvent = await errorPromise;
@@ -22,7 +24,7 @@ test('First-party error with React frames should not be tagged as third-party co
// In production, TEST_ENV=production is shared by both turbopack and webpack variants.
// Only assert when the build is actually turbopack.
if (errorEvent.tags?.turbopack) {
- // The integration uses `apply-tag-if-contains-third-party-frames` which tags errors
+ // By default, the integration uses `apply-tag-if-contains-third-party-frames` which tags errors
// if ANY frame is third-party. This error is thrown inside a React onClick handler,
// so the stack trace contains React frames from node_modules. These must NOT be
// treated as third-party — the module metadata injection must cover node_modules too
@@ -30,3 +32,46 @@ test('First-party error with React frames should not be tagged as third-party co
expect(errorEvent.tags?.third_party_code).toBeUndefined();
}
});
+
+test('retains application errors with dependency frames when dependency metadata is excluded', async ({ page }) => {
+ test.skip(process.env.NEXT_PUBLIC_EXCLUDE_APPLICATION_KEY_DEPENDENCIES !== '1');
+
+ const errorPromise = waitForError('nextjs-16', event => {
+ return event.exception?.values?.some(value => value.value === 'application-key-dependency-error') ?? false;
+ });
+
+ await page.goto('/third-party-filter');
+ await page.waitForFunction(() => '__SENTRY__' in window);
+ await page.locator('#dependency-error-btn').click();
+
+ const event = await errorPromise;
+ const exception = event.exception?.values?.[0];
+ expect(exception?.value).toBe('application-key-dependency-error');
+ expect(exception?.mechanism).toEqual({ handled: true, type: 'generic' });
+ expect(event.tags?.third_party_code).toBeUndefined();
+ expect(event.tags?.turbopack).toBe(true);
+ expect(new Set(exception?.stacktrace?.frames?.map(frame => frame.filename)).size).toBeGreaterThan(1);
+});
+
+test('classifies dependency-only errors according to dependency metadata inclusion', async ({ page }) => {
+ test.skip(isVinext || isDevMode, 'Checks dependency-only chunks in production Next.js builds');
+
+ const errorPromise = waitForError('nextjs-16', event => {
+ return event.exception?.values?.some(value => value.value === 'application-key-dependency-only-error') ?? false;
+ });
+
+ await page.goto('/third-party-filter');
+ await page.waitForFunction(() => '__SENTRY__' in window);
+ await page.locator('#dependency-only-error-btn').click();
+
+ const event = await errorPromise;
+ const frames = event.exception?.values?.[0]?.stacktrace?.frames;
+ expect(frames).toHaveLength(1);
+
+ const filename = frames![0]!.filename;
+ const chunkResponse = await page.request.get(filename!.replace('app://', ''));
+ expect(await chunkResponse.text()).toContain('application-key-dependency-only-error');
+ expect(event.tags?.third_party_code).toBe(
+ process.env.NEXT_PUBLIC_EXCLUDE_APPLICATION_KEY_DEPENDENCIES === '1' ? true : undefined,
+ );
+});
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 2f9889c336f1..d4bd74483a34 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
@@ -9,7 +9,8 @@
"test:dev": "TEST_ENV=development playwright test",
"test:build": "pnpm install && pnpm build",
"test:test-build": "pnpm ts-node --script-mode assert-build.ts",
- "test:build-15": "pnpm install && pnpm add next@15 && pnpm add react@latest && pnpm add react-dom@latest && pnpm build",
+ "test:install-15": "pnpm install && pnpm add next@15 && pnpm add react@latest && pnpm add react-dom@latest",
+ "test:build-15": "pnpm test:install-15 && pnpm build",
"test:assert": "pnpm test:test-build && pnpm test:prod && pnpm test:dev"
},
"dependencies": {
@@ -29,12 +30,27 @@
"ts-node": "10.9.1"
},
"sentryTest": {
- "runner": "namespace-profile-sentry-javascript",
"variants": [
+ {
+ "build-command": "pnpm install",
+ "label": "nextjs-app-dir (development)",
+ "assert-command": "pnpm test:dev"
+ },
{
"build-command": "pnpm test:build-15",
- "label": "nextjs-app-dir (next@15)"
+ "label": "nextjs-app-dir (next@15, production)",
+ "assert-command": "pnpm test:test-build && pnpm test:prod"
+ },
+ {
+ "build-command": "pnpm test:install-15",
+ "label": "nextjs-app-dir (next@15, development)",
+ "assert-command": "pnpm test:dev"
}
- ]
+ ],
+ "baseVariant": {
+ "build-command": "pnpm test:build",
+ "label": "nextjs-app-dir (production)",
+ "assert-command": "pnpm test:test-build && pnpm test:prod"
+ }
}
}
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 d3e2f51b92ff..23d94904f614 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
@@ -30,7 +30,6 @@
"ts-node": "10.9.1"
},
"sentryTest": {
- "runner": "namespace-profile-sentry-javascript",
"variants": [
{
"build-command": "pnpm test:build-15",
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 05907e35b114..3bfee836718a 100644
--- a/dev-packages/e2e-tests/test-applications/nitro-3/package.json
+++ b/dev-packages/e2e-tests/test-applications/nitro-3/package.json
@@ -21,7 +21,7 @@
"devDependencies": {
"@playwright/test": "1.64.1",
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz",
- "@sentry/conventions": "0.29.0",
+ "@sentry/conventions": "0.30.0",
"@sentry/core": "latest || *",
"nitro": "^3.0.260522-beta",
"rolldown": "latest",
diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-task-error.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-task-error.ts
new file mode 100644
index 000000000000..50b4dd06f173
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-task-error.ts
@@ -0,0 +1,8 @@
+import { defineHandler } from 'nitro/h3';
+import { runTask } from 'nitro/task';
+
+export default defineHandler(async () => {
+ // Catch the error so that only the task instrumentation can report it, not the `error` hook.
+ await runTask('test-error').catch(() => undefined);
+ return { status: 'ok' };
+});
diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/tasks/test-error.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/tasks/test-error.ts
new file mode 100644
index 000000000000..6f0137f53836
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/tasks/test-error.ts
@@ -0,0 +1,8 @@
+import { defineTask } from 'nitro/task';
+
+export default defineTask({
+ meta: { name: 'test-error' },
+ run: () => {
+ throw new Error('This is a task error');
+ },
+});
diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/tasks.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/tasks.test.ts
new file mode 100644
index 000000000000..6d421b08fb9f
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/tasks.test.ts
@@ -0,0 +1,24 @@
+import { expect, test } from '@playwright/test';
+import { waitForError } from '@sentry-internal/test-utils';
+
+test('Sends an error thrown in a task to Sentry', async ({ request }) => {
+ const errorEventPromise = waitForError('nitro-3', event => {
+ return !!event.exception?.values?.some(v => v.value === 'This is a task error');
+ });
+
+ await request.get('/api/test-task-error');
+
+ const errorEvent = await errorEventPromise;
+
+ expect(errorEvent.exception?.values).toEqual([
+ expect.objectContaining({
+ type: 'Error',
+ value: 'This is a task error',
+ mechanism: expect.objectContaining({
+ handled: false,
+ type: 'auto.function.nitro.task',
+ }),
+ }),
+ ]);
+ expect(errorEvent.contexts?.nitro).toEqual({ task: '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 b7c6de66d47c..b79a665ba851 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
@@ -13,6 +13,7 @@ export default defineConfig({
// @ts-expect-error
withSentryConfig({
serverDir: './server',
+ experimental: { tasks: true },
...(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
deleted file mode 100644
index 9f7d8ae4fa75..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/package.json
+++ /dev/null
@@ -1,36 +0,0 @@
-{
- "name": "node-anthropic-send-to-sentry",
- "description": "The Anthropic integration as a user runs it: a plain Sentry.init on an express app, real chat, streaming, stream-helper and tool-call requests through OpenRouter, and the gen_ai spans read back from a real Sentry project",
- "version": "1.0.0",
- "private": true,
- "type": "module",
- "scripts": {
- "build": "tsc",
- "start": "node --import ./dist/instrument.js dist/app.js",
- "test": "playwright test",
- "clean": "npx rimraf node_modules dist pnpm-lock.yaml",
- "test:build": "pnpm install && pnpm build",
- "test:build-latest": "pnpm install && pnpm add @anthropic-ai/sdk@latest && pnpm build",
- "test:assert": "pnpm test"
- },
- "dependencies": {
- "@anthropic-ai/sdk": "0.63.0",
- "@sentry/node": "file:../../packed/sentry-node-packed.tgz",
- "express": "^4.21.2"
- },
- "devDependencies": {
- "@playwright/test": "1.64.1",
- "@types/express": "^4.17.21",
- "@types/node": "^24.0.0",
- "typescript": "~5.9.0"
- },
- "sentryTest": {
- "optional": true,
- "optionalVariants": [
- {
- "build-command": "pnpm test:build-latest",
- "label": "node-anthropic-send-to-sentry (latest)"
- }
- ]
- }
-}
diff --git a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/playwright.config.ts b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/playwright.config.ts
deleted file mode 100644
index ef4764ce8d73..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/playwright.config.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { defineConfig } from '@playwright/test';
-
-const port = 3030;
-
-export default defineConfig({
- testDir: './tests',
- /*
- * Spans take ~2min to become queryable via the trace endpoint, and each poll has its own 180s
- * budget. The first test polls twice in a row (the model span, then its parent), so the ceiling
- * has to hold two polls back to back.
- */
- 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-anthropic-send-to-sentry/src/app.ts b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/src/app.ts
deleted file mode 100644
index 693f1ef4f3b3..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/src/app.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-// `instrument.ts` is preloaded via `node --import`, so Sentry is already initialised here.
-import * as Sentry from '@sentry/node';
-import Anthropic from '@anthropic-ai/sdk';
-import express from 'express';
-
-const apiKey = process.env.E2E_OPENROUTER_API_KEY;
-if (!apiKey) {
- throw new Error('E2E_OPENROUTER_API_KEY is not set');
-}
-
-// OpenRouter serves an Anthropic-compatible `/api/v1/messages`, so the stock client only needs a
-// different base URL. It authenticates with a bearer token, so the key goes in `authToken`
-// (Authorization: Bearer) rather than `apiKey` (x-api-key). The model is incidental: what is under
-// test is the SDK's own request/response code path, which is what Sentry instruments.
-const client = new Anthropic({ authToken: apiKey, baseURL: 'https://openrouter.ai/api', fetch: fetchWithoutDone });
-
-/**
- * OpenRouter ends each stream with an OpenAI-style `data: [DONE]` frame, which is not part of
- * Anthropic's protocol. Since 0.136.0 the SDK JSON-parses every named event, so this frame
- * makes it throw. Drop the frame before the SDK sees it.
- */
-async function fetchWithoutDone(input: string | URL | Request, init?: RequestInit): Promise {
- const response = await fetch(input, init);
- if (!response.body || !response.headers.get('content-type')?.includes('text/event-stream')) {
- return response;
- }
-
- let buffer = '';
- const keepFrame = (frame: string): boolean => !/^data: \[DONE\]$/m.test(frame);
- const body = response.body.pipeThrough(new TextDecoderStream()).pipeThrough(
- new TransformStream({
- transform(chunk, controller) {
- buffer += chunk;
- const frames = buffer.split(/(?<=\r?\n\r?\n)/);
- buffer = /\r?\n\r?\n$/.test(frames[frames.length - 1] ?? '') ? '' : (frames.pop() ?? '');
- controller.enqueue(frames.filter(keepFrame).join(''));
- },
- flush(controller) {
- if (keepFrame(buffer)) {
- controller.enqueue(buffer);
- }
- },
- }),
- );
-
- return new Response(body.pipeThrough(new TextEncoderStream()), {
- status: response.status,
- statusText: response.statusText,
- headers: response.headers,
- });
-}
-
-const MODEL = 'openai/gpt-4o-mini';
-
-const SHORT_ANSWER = 'Answer in at most five words.';
-const CHAT_PROMPT = `What is the capital of France? ${SHORT_ANSWER}`;
-// Deliberately does not name the tool: `tool_choice` forces the call.
-const WEATHER_PROMPT = `What is the weather in Paris? ${SHORT_ANSWER}`;
-const SYSTEM = 'You are a helpful assistant used by an automated test.';
-
-const WEATHER_TOOL = {
- name: 'get_weather',
- description: 'Get the current weather for a city.',
- input_schema: {
- type: 'object' as const,
- properties: { city: { type: 'string', description: 'The city name' } },
- required: ['city'],
- },
-};
-
-const app = express();
-
-/** The trace this request is recorded under, so the test can look its spans up in Sentry. */
-function currentTraceId(): string | undefined {
- return Sentry.getActiveSpan()?.spanContext().traceId;
-}
-
-/** The text of a message's first text block. */
-function textOf(message: Anthropic.Message): string {
- const first = message.content[0];
- return first?.type === 'text' ? first.text : '';
-}
-
-// Each route makes one call through the `@anthropic-ai/sdk` client and answers with the trace id, so
-// the test can find the request's spans in Sentry.
-
-app.get('/chat', async (_req, res, next) => {
- try {
- const message = await client.messages.create({
- model: MODEL,
- max_tokens: 32,
- system: SYSTEM,
- messages: [{ role: 'user', content: CHAT_PROMPT }],
- });
- res.send({ traceId: currentTraceId(), answer: textOf(message) });
- } catch (error) {
- next(error);
- }
-});
-
-// `messages.create({ stream: true })` returns an async iterable of events the caller drains.
-app.get('/chat-stream', async (_req, res, next) => {
- try {
- const stream = await client.messages.create({
- model: MODEL,
- max_tokens: 32,
- system: SYSTEM,
- messages: [{ role: 'user', content: CHAT_PROMPT }],
- stream: true,
- });
-
- let answer = '';
- for await (const event of stream) {
- if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
- answer += event.delta.text;
- }
- }
- res.send({ traceId: currentTraceId(), answer });
- } catch (error) {
- next(error);
- }
-});
-
-// `messages.stream()` is the SDK's streaming helper: it accumulates the events into the final
-// message, and the integration instruments it separately from `create`.
-app.get('/stream-helper', async (_req, res, next) => {
- try {
- const message = await client.messages
- .stream({
- model: MODEL,
- max_tokens: 32,
- system: SYSTEM,
- messages: [{ role: 'user', content: CHAT_PROMPT }],
- })
- .finalMessage();
- res.send({ traceId: currentTraceId(), answer: textOf(message) });
- } catch (error) {
- next(error);
- }
-});
-
-app.get('/tools', async (_req, res, next) => {
- try {
- const message = await client.messages.create({
- model: MODEL,
- max_tokens: 64,
- messages: [{ role: 'user', content: WEATHER_PROMPT }],
- tools: [WEATHER_TOOL],
- tool_choice: { type: 'tool', name: 'get_weather' },
- });
- res.send({ traceId: currentTraceId(), toolUse: message.content.filter(block => block.type === 'tool_use') });
- } catch (error) {
- next(error);
- }
-});
-
-app.use((error: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
- res.status(500).send({ message: error.message });
-});
-
-const port = Number(process.env.PORT ?? 3030);
-app.listen(port, () => {
- // eslint-disable-next-line no-console
- console.log(`node-anthropic-send-to-sentry listening on port ${port}`);
-});
diff --git a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/src/instrument.ts
deleted file mode 100644
index 7d031b0e6d8e..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/src/instrument.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import * as Sentry from '@sentry/node';
-
-// The setup from the docs, nothing more: preloaded with `node --import`, no tunnel, so the spans go to
-// the real Sentry project behind `E2E_TEST_DSN`, where the tests read them back. The Anthropic integration
-// is on by default; its runtime channel injection is what picks up the `@anthropic-ai/sdk` client in app.ts.
-Sentry.init({
- dsn: process.env.E2E_TEST_DSN,
- environment: 'qa', // dynamic sampling bias to keep transactions
- tracesSampleRate: 1,
-});
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
deleted file mode 100644
index e7a135c2368c..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/tests/send-to-sentry.test.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-import { expect, test } from '@playwright/test';
-import type { TraceItem } from './utils/sentry-api';
-import {
- EVENT_POLLING_OPTIONS,
- fetchSpanAttributes,
- fetchTrace,
- findSpanInTrace,
- flattenTrace,
-} from './utils/sentry-api';
-
-// Mirrors src/app.ts.
-const MODEL = 'openai/gpt-4o-mini';
-
-type Attributes = Record;
-
-/** Sends one request to the app and returns the trace its request span was recorded under. */
-async function requestTrace(baseURL: string | undefined, path: string): Promise {
- const response = await fetch(`${baseURL}${path}`);
- const body = await response.text();
- expect(response.status, `${path} answered ${response.status}: ${body}`).toBe(200);
-
- const { traceId } = JSON.parse(body) as { traceId?: string };
- expect(traceId, `${path} did not report a trace id: ${body}`).toMatch(/^[0-9a-f]{32}$/);
-
- console.log(`${path}: https://${process.env.E2E_TEST_SENTRY_ORG_SLUG}.sentry.io/explore/traces/trace/${traceId}/`);
- return traceId!;
-}
-
-/**
- * Polls Sentry until the trace holds a span with `op`, then returns that span and its attributes. The
- * trace endpoint only carries the span's op, name and place in the tree; the attributes need a second
- * lookup, which is polled as well since the two can land apart.
- */
-async function waitForSpan(traceId: string, op: string): Promise<{ span: TraceItem; attributes: Attributes }> {
- let found: { span: TraceItem; attributes: Attributes } | undefined;
-
- await expect
- .poll(async () => {
- const span = await findSpanInTrace(traceId, op);
- const attributes = span?.event_id ? await fetchSpanAttributes(traceId, span.event_id) : undefined;
- found = span && attributes ? { span, attributes } : undefined;
- return found;
- }, EVENT_POLLING_OPTIONS)
- .toBeDefined();
-
- return found!;
-}
-
-/** Whether the model-call span sits somewhere below the request span (`http.server`) of the trace. */
-async function isModelSpanUnderRequestSpan(traceId: string, modelSpanId: string): Promise {
- const requestSpan = flattenTrace(await fetchTrace(traceId)).find(item => item.op === 'http.server');
- return flattenTrace(requestSpan?.children ?? []).some(item => item.event_id === modelSpanId);
-}
-
-/**
- * The attributes every successful model-call span carries once Sentry has stored it, whatever the
- * model answers. Model-dependent values (token counts, response id and model) are checked for shape,
- * not content. Note the names are the stored ones: `origin` and `span.status` rather than the
- * `sentry.*` attributes the SDK sends.
- */
-function expectModelCallAttributes(span: TraceItem, attributes: Attributes): void {
- expect(span.description).toBe(`chat ${MODEL}`);
- expect(attributes).toMatchObject({
- 'span.op': 'gen_ai.chat',
- 'span.status': 'ok',
- origin: 'auto.ai.anthropic',
- 'gen_ai.operation.name': 'chat',
- 'gen_ai.provider.name': 'anthropic',
- 'gen_ai.request.model': MODEL,
- });
- expect(typeof attributes['gen_ai.response.id']).toBe('string');
- expect(typeof attributes['gen_ai.response.model']).toBe('string');
- expect(attributes['gen_ai.usage.output_tokens']).toBeGreaterThan(0);
- expect(attributes['gen_ai.usage.total_tokens']).toBeGreaterThan(0);
-}
-
-/** The prompt and the answer, which the SDK records by default and which have to survive the trip. */
-function expectRecordedConversation(attributes: Attributes): void {
- // The answer is sent as `gen_ai.response.text` and stored as `gen_ai.output.messages`.
- expect(attributes['gen_ai.system_instructions']).toContain('automated test');
- expect(attributes['gen_ai.input.messages']).toContain('capital of France');
- expect(typeof attributes['gen_ai.output.messages']).toBe('string');
-}
-
-/** `gen_ai.response.finish_reasons` is a JSON array, only recorded for streamed calls. */
-function finishReasons(attributes: Attributes): string[] {
- const raw = attributes['gen_ai.response.finish_reasons'];
- expect(typeof raw, 'gen_ai.response.finish_reasons').toBe('string');
- return JSON.parse(raw as string);
-}
-
-test('Sends a message to Sentry as a gen_ai.chat span under the request span', async ({ baseURL }) => {
- const traceId = await requestTrace(baseURL, '/chat');
-
- const { span, attributes } = await waitForSpan(traceId, 'gen_ai.chat');
-
- expectModelCallAttributes(span, attributes);
- expect(attributes['gen_ai.usage.input_tokens']).toBeGreaterThan(0);
- expect(attributes['gen_ai.response.streaming']).toBeUndefined();
- expectRecordedConversation(attributes);
-
- // The request span is the segment; it ends last, so it can land after its children.
- await expect.poll(() => isModelSpanUnderRequestSpan(traceId, span.event_id!), EVENT_POLLING_OPTIONS).toBe(true);
-});
-
-test('Sends a streamed message to Sentry with its token usage', async ({ baseURL }) => {
- const traceId = await requestTrace(baseURL, '/chat-stream');
-
- const { span, attributes } = await waitForSpan(traceId, 'gen_ai.chat');
-
- expectModelCallAttributes(span, attributes);
- expect(attributes['gen_ai.response.streaming']).toBe(true);
- expect(finishReasons(attributes)).toContain('end_turn');
- expectRecordedConversation(attributes);
- // The input usage is not asserted: the instrumentation takes it from `message_start`, where
- // OpenRouter reports 0, and ignores the real count OpenRouter (and the Anthropic API) put in
- // `message_delta`. Output usage comes from `message_delta` and is covered by the shared checks.
-});
-
-test('Sends a message made with the stream helper to Sentry', async ({ baseURL }) => {
- const traceId = await requestTrace(baseURL, '/stream-helper');
-
- const { span, attributes } = await waitForSpan(traceId, 'gen_ai.chat');
-
- // `messages.stream()` calls `create` underneath, which must not produce a second span. The parent
- // lands last, so count after it; the short poll only rides out a dropped connection.
- await expect.poll(() => isModelSpanUnderRequestSpan(traceId, span.event_id!), EVENT_POLLING_OPTIONS).toBe(true);
- await expect
- .poll(async () => flattenTrace(await fetchTrace(traceId)).filter(item => item.op === 'gen_ai.chat').length, {
- timeout: 30_000,
- intervals: [5_000],
- })
- .toBe(1);
- expectModelCallAttributes(span, attributes);
- expect(attributes['gen_ai.response.streaming']).toBe(true);
- expect(finishReasons(attributes)).toContain('end_turn');
- expectRecordedConversation(attributes);
-});
-
-test('Sends a message that returned a tool use to Sentry', async ({ baseURL }) => {
- const traceId = await requestTrace(baseURL, '/tools');
-
- const { span, attributes } = await waitForSpan(traceId, 'gen_ai.chat');
-
- expectModelCallAttributes(span, attributes);
- expect(attributes['gen_ai.usage.input_tokens']).toBeGreaterThan(0);
- expect(attributes['gen_ai.tool.definitions']).toContain('get_weather');
- expect(attributes['gen_ai.input.messages']).toContain('weather in Paris');
- // The SDK also sends the returned tool use as `gen_ai.response.tool_calls`, but that attribute is
- // not stored under that name, and stop reasons are only recorded for streamed calls.
-});
diff --git a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/tests/utils/sentry-api.ts b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/tests/utils/sentry-api.ts
deleted file mode 100644
index 521b830eefcb..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/tests/utils/sentry-api.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-const authToken = process.env.E2E_TEST_AUTH_TOKEN;
-const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG;
-const sentryTestProject = process.env.E2E_TEST_SENTRY_PROJECT;
-
-/**
- * Spans only become queryable once they have made it through to EAP, which takes
- * noticeably longer than the error pipeline (~2min vs ~20s when this was measured).
- */
-export const EVENT_POLLING_OPTIONS = { timeout: 180_000, intervals: [5_000] };
-
-/**
- * A node of the span tree returned by the organization trace endpoint. Spans, errors and
- * occurrences all share this shape and are discriminated by `event_type`.
- */
-export interface TraceItem {
- /** On a span this is the span id. */
- event_id?: string;
- event_type?: 'span' | 'error' | 'occurrence' | 'uptime_check';
- op?: string;
- /** On a span this is the span name. */
- description?: string;
- children?: TraceItem[];
- errors?: TraceItem[];
- occurrences?: TraceItem[];
-}
-
-/**
- * One request to the Sentry API with the E2E token. Returns `undefined` when the connection
- * fails or drops mid-response: over the minutes a test polls, the API closes the odd
- * connection ("TypeError: terminated"), and an error thrown inside `expect.poll` fails the
- * test instead of letting it retry, so a network error is treated like a "not there yet".
- */
-async function fetchSentryApi(what: string, path: string): Promise<{ status: number; body: string } | undefined> {
- try {
- const response = await fetch(`https://sentry.io/api/0/${path}`, {
- headers: { Authorization: `Bearer ${authToken}` },
- });
- return { status: response.status, body: await response.text() };
- } catch (error) {
- console.log(`${what} failed: ${error}`);
- return undefined;
- }
-}
-
-export async function fetchTrace(traceId: string): Promise {
- const what = `Trace lookup for ${traceId}`;
- const response = await fetchSentryApi(what, `organizations/${sentryTestOrgSlug}/trace/${traceId}/?statsPeriod=1h`);
- if (!response) {
- return [];
- }
-
- // The trace endpoint is org scoped, so the auth token needs `org:read` on top of the
- // project scopes the other assertions rely on. That never resolves by waiting, so fail
- // loudly instead of polling until the timeout and reporting it as a missing event.
- if (response.status === 401 || response.status === 403) {
- throw new Error(
- `${what} was rejected with ${response.status}: ${response.body}. ` +
- 'E2E_TEST_AUTH_TOKEN needs the `org:read` scope.',
- );
- }
-
- // Empty traces and the occasional rate limit are expected while polling, so treat anything
- // else that is not a success as "not there yet" -- but log it, since a rejected request and
- // a trace that has not landed are otherwise indistinguishable.
- if (response.status !== 200) {
- console.log(`${what} returned ${response.status}: ${response.body}`);
- return [];
- }
-
- return JSON.parse(response.body);
-}
-
-/**
- * Errors attach to whichever span was active when they were captured, and relocate from the
- * top level into that span once it lands, so a given event can surface at any depth.
- */
-export function flattenTrace(items: TraceItem[]): TraceItem[] {
- return items.flatMap(item => [
- item,
- ...flattenTrace(item.children ?? []),
- ...flattenTrace(item.errors ?? []),
- ...flattenTrace(item.occurrences ?? []),
- ]);
-}
-
-export async function findSpanInTrace(traceId: string, op: string): Promise {
- return flattenTrace(await fetchTrace(traceId)).find(item => item.op === op);
-}
-
-/**
- * The attributes of one span, keyed by name, as Sentry stores them. The trace endpoint only carries
- * a span's op, name and place in the tree; the attributes come from the project trace-items
- * endpoint. Returns `undefined` while the span has not landed.
- *
- * Ingest normalises what the SDK sent: `sentry.origin` comes back as `origin`, `sentry.op` as
- * `span.op`, `gen_ai.response.text` as `gen_ai.output.messages`, and some numbers are indexed as
- * tags (`tags[,number]`). Integers are returned as strings and are converted here; everything
- * else is passed through as is.
- */
-export async function fetchSpanAttributes(
- traceId: string,
- spanId: string,
-): Promise | undefined> {
- const what = `Span lookup for ${spanId}`;
- const response = await fetchSentryApi(
- what,
- `projects/${sentryTestOrgSlug}/${sentryTestProject}/trace-items/${spanId}/?trace_id=${traceId}&item_type=spans`,
- );
- if (!response) {
- return undefined;
- }
-
- if (response.status === 401 || response.status === 403) {
- throw new Error(
- `${what} was rejected with ${response.status}: ${response.body}. ` +
- 'E2E_TEST_AUTH_TOKEN needs the `org:read` scope.',
- );
- }
-
- if (response.status !== 200) {
- if (response.status !== 404) {
- console.log(`${what} returned ${response.status}: ${response.body}`);
- }
- return undefined;
- }
-
- const { attributes } = JSON.parse(response.body) as { attributes?: { name: string; type: string; value: unknown }[] };
- return Object.fromEntries(
- (attributes ?? []).map(({ name, type, value }) => [name, type === 'int' ? Number(value) : value]),
- );
-}
diff --git a/dev-packages/e2e-tests/test-applications/node-bullmq/docker-compose.yml b/dev-packages/e2e-tests/test-applications/node-bullmq/docker-compose.yml
new file mode 100644
index 000000000000..15a423ffbc07
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/node-bullmq/docker-compose.yml
@@ -0,0 +1,7 @@
+services:
+ redis:
+ image: redis:8
+ restart: always
+ container_name: e2e-tests-node-bullmq-redis
+ ports:
+ - '6379:6379'
diff --git a/dev-packages/e2e-tests/test-applications/node-bullmq/global-setup.mjs b/dev-packages/e2e-tests/test-applications/node-bullmq/global-setup.mjs
new file mode 100644
index 000000000000..438b88b61794
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/node-bullmq/global-setup.mjs
@@ -0,0 +1,13 @@
+import { execSync } from 'child_process';
+import { dirname } from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+export default async function globalSetup() {
+ // Start Redis via Docker Compose
+ execSync('docker compose up -d --wait', {
+ cwd: __dirname,
+ stdio: 'inherit',
+ });
+}
diff --git a/dev-packages/e2e-tests/test-applications/node-bullmq/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/node-bullmq/global-teardown.mjs
new file mode 100644
index 000000000000..35ce41179193
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/node-bullmq/global-teardown.mjs
@@ -0,0 +1,13 @@
+import { execSync } from 'child_process';
+import { dirname } from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+export default async function globalTeardown() {
+ // Stop Redis and remove containers
+ execSync('docker compose down --volumes', {
+ cwd: __dirname,
+ stdio: 'inherit',
+ });
+}
diff --git a/dev-packages/e2e-tests/test-applications/node-bullmq/package.json b/dev-packages/e2e-tests/test-applications/node-bullmq/package.json
new file mode 100644
index 000000000000..b86c4b846925
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/node-bullmq/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "node-bullmq",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "start": "node src/app.mjs",
+ "clean": "npx rimraf node_modules pnpm-lock.yaml",
+ "test": "playwright test",
+ "test:build": "pnpm install",
+ "test:assert": "pnpm test"
+ },
+ "dependencies": {
+ "@sentry/node": "file:../../packed/sentry-node-packed.tgz",
+ "bullmq": "^5.0.0",
+ "express": "^4.21.0"
+ },
+ "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-bullmq/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-bullmq/playwright.config.mjs
new file mode 100644
index 000000000000..d5fd0b394f15
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/node-bullmq/playwright.config.mjs
@@ -0,0 +1,11 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+
+const config = getPlaywrightConfig({
+ startCommand: `pnpm start`,
+});
+
+export default {
+ ...config,
+ globalSetup: './global-setup.mjs',
+ globalTeardown: './global-teardown.mjs',
+};
diff --git a/dev-packages/e2e-tests/test-applications/node-bullmq/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/node-bullmq/pnpm-workspace.yaml
new file mode 100644
index 000000000000..0f973fea7a88
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/node-bullmq/pnpm-workspace.yaml
@@ -0,0 +1,3 @@
+allowBuilds:
+ msgpackr-extract: true
+minimumReleaseAge: 0
diff --git a/dev-packages/e2e-tests/test-applications/node-bullmq/src/app.mjs b/dev-packages/e2e-tests/test-applications/node-bullmq/src/app.mjs
new file mode 100644
index 000000000000..4b51a6f775f5
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/node-bullmq/src/app.mjs
@@ -0,0 +1,49 @@
+import './instrument.mjs';
+
+import * as Sentry from '@sentry/node';
+import { Queue, Worker } from 'bullmq';
+import express from 'express';
+
+const app = express();
+const port = 3030;
+
+const connection = { host: '127.0.0.1', port: 6379 };
+const telemetry = new Sentry.BullMQTelemetry({ enableMetrics: true });
+
+const testQueue = new Queue('test-queue', { connection, telemetry });
+
+const worker = new Worker(
+ 'test-queue',
+ async job => {
+ if (job.name === 'fail-job') {
+ throw new Error('Test error from BullMQ processor');
+ }
+
+ if (job.name === 'breadcrumb-job') {
+ Sentry.addBreadcrumb({ message: 'breadcrumb-from-bullmq-processor' });
+ }
+
+ return { success: true };
+ },
+ { connection, telemetry },
+);
+
+worker.on('error', err => {
+ console.error('Worker error:', err);
+});
+
+app.get('/enqueue/:jobName', async (req, res) => {
+ await testQueue.add(req.params.jobName, { data: 'test' });
+ res.send('Job enqueued');
+});
+
+app.get('/check-isolation', async (req, res) => {
+ Sentry.captureException(new Error('Isolation check'));
+ res.send('Isolation check');
+});
+
+Sentry.setupExpressErrorHandler(app);
+
+app.listen(port, () => {
+ console.log(`App listening on port ${port}`);
+});
diff --git a/dev-packages/e2e-tests/test-applications/node-bullmq/src/instrument.mjs b/dev-packages/e2e-tests/test-applications/node-bullmq/src/instrument.mjs
new file mode 100644
index 000000000000..5644b907f3f1
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/node-bullmq/src/instrument.mjs
@@ -0,0 +1,12 @@
+import * as Sentry from '@sentry/node';
+
+Sentry.init({
+ dsn: process.env.E2E_TEST_DSN,
+ tunnel: 'http://localhost:3031/',
+ tracesSampleRate: 1.0,
+ // A fixed release, so that a release from the CI environment does not change the span attributes.
+ release: '1.0',
+ // The Context integration adds machine-specific attributes to segment spans, and adds the `os.*` ones only after an
+ // async lookup. Without it, the tests can compare all span attributes.
+ integrations: integrations => integrations.filter(integration => integration.name !== 'Context'),
+});
diff --git a/dev-packages/e2e-tests/test-applications/node-bullmq/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-bullmq/start-event-proxy.mjs
new file mode 100644
index 000000000000..2426471fe487
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/node-bullmq/start-event-proxy.mjs
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'node-bullmq',
+});
diff --git a/dev-packages/e2e-tests/test-applications/node-bullmq/tests/bullmq.test.ts b/dev-packages/e2e-tests/test-applications/node-bullmq/tests/bullmq.test.ts
new file mode 100644
index 000000000000..2508bdb5b2b9
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/node-bullmq/tests/bullmq.test.ts
@@ -0,0 +1,229 @@
+import { expect, test } from '@playwright/test';
+import { getSpanOp, waitForError, waitForMetric, waitForStreamedSpan } from '@sentry-internal/test-utils';
+
+test('Creates a queue.publish span when adding a job', async ({ baseURL }) => {
+ const publishSpanPromise = waitForStreamedSpan('node-bullmq', span => {
+ return getSpanOp(span) === 'queue.publish' && span.attributes['messaging.bullmq.job.name']?.value === 'publish-job';
+ });
+
+ await fetch(`${baseURL}/enqueue/publish-job`);
+
+ const publishSpan = await publishSpanPromise;
+
+ expect(publishSpan.name).toBe('add test-queue.publish-job');
+ expect(publishSpan.is_segment).toBe(false);
+ expect(publishSpan.status).toBe('ok');
+ expect(publishSpan.attributes).toEqual({
+ 'sentry.op': { type: 'string', value: 'queue.publish' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.producer' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'test-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'add' },
+ 'messaging.message.id': { type: 'string', value: expect.any(String) },
+ 'messaging.bullmq.job.name': { type: 'string', value: 'publish-job' },
+ 'sentry.environment': { type: 'string', value: 'production' },
+ 'sentry.is_localhost': { type: 'boolean', value: true },
+ 'sentry.release': { type: 'string', value: '1.0' },
+ 'sentry.sdk.name': { type: 'string', value: 'sentry.javascript.node' },
+ 'sentry.sdk.version': { type: 'string', value: expect.any(String) },
+ 'sentry.segment.id': { type: 'string', value: expect.stringMatching(/^[\da-f]{16}$/) },
+ 'sentry.segment.name': { type: 'string', value: 'GET /enqueue/:jobName' },
+ 'sentry.trace_lifecycle': { type: 'string', value: 'stream' },
+ });
+});
+
+test('Creates a segment span for queue.process when processing a job', async ({ baseURL }) => {
+ const processSpanPromise = waitForStreamedSpan('node-bullmq', span => {
+ return (
+ span.is_segment &&
+ getSpanOp(span) === 'queue.process' &&
+ span.attributes['messaging.bullmq.job.name']?.value === 'process-job'
+ );
+ });
+
+ await fetch(`${baseURL}/enqueue/process-job`);
+
+ const processSpan = await processSpanPromise;
+
+ expect(processSpan.name).toBe('process test-queue');
+ expect(processSpan.status).toBe('ok');
+ expect(processSpan.attributes).toEqual({
+ 'sentry.op': { type: 'string', value: 'queue.process' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.consumer' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'test-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'process' },
+ 'messaging.message.id': { type: 'string', value: expect.any(String) },
+ 'messaging.message.retry.count': { type: 'integer', value: 0 },
+ 'messaging.client.id': { type: 'string', value: expect.any(String) },
+ 'messaging.bullmq.job.name': { type: 'string', value: 'process-job' },
+ 'messaging.bullmq.job.processed_timestamp': { type: 'integer', value: expect.any(Number) },
+ 'messaging.bullmq.job.attempt_finished_timestamp': { type: 'integer', value: expect.any(Number) },
+ 'sentry.environment': { type: 'string', value: 'production' },
+ 'sentry.is_localhost': { type: 'boolean', value: false },
+ 'sentry.release': { type: 'string', value: '1.0' },
+ 'sentry.sample_rate': { type: 'integer', value: 1 },
+ 'sentry.sdk.integrations': { type: 'array', value: expect.any(Array) },
+ 'sentry.sdk.name': { type: 'string', value: 'sentry.javascript.node' },
+ 'sentry.sdk.version': { type: 'string', value: expect.any(String) },
+ 'sentry.segment.id': { type: 'string', value: processSpan.span_id },
+ 'sentry.segment.name': { type: 'string', value: 'process test-queue' },
+ 'sentry.segment.name.source': { type: 'string', value: 'custom' },
+ 'sentry.trace_lifecycle': { type: 'string', value: 'stream' },
+ });
+});
+
+test('Sends exception to Sentry on error in job processor', async ({ baseURL }) => {
+ const errorEventPromise = waitForError('node-bullmq', event => {
+ return !event.type && event.exception?.values?.[0]?.value === 'Test error from BullMQ processor';
+ });
+ const processSpanPromise = waitForStreamedSpan('node-bullmq', span => {
+ return (
+ span.is_segment &&
+ getSpanOp(span) === 'queue.process' &&
+ span.attributes['messaging.bullmq.job.name']?.value === 'fail-job'
+ );
+ });
+
+ await fetch(`${baseURL}/enqueue/fail-job`);
+
+ const errorEvent = await errorEventPromise;
+ const processSpan = await processSpanPromise;
+
+ expect(errorEvent.exception?.values).toHaveLength(1);
+ expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({
+ handled: false,
+ type: 'auto.queue.bullmq',
+ });
+ expect(processSpan.status).toBe('error');
+ expect(errorEvent.contexts?.trace?.trace_id).toBe(processSpan.trace_id);
+ expect(errorEvent.contexts?.trace?.span_id).toBe(processSpan.span_id);
+});
+
+test('BullMQ processor breadcrumbs do not leak into subsequent HTTP requests', async ({ baseURL }) => {
+ const processSpanPromise = waitForStreamedSpan('node-bullmq', span => {
+ return (
+ span.is_segment &&
+ getSpanOp(span) === 'queue.process' &&
+ span.attributes['messaging.bullmq.job.name']?.value === 'breadcrumb-job'
+ );
+ });
+
+ await fetch(`${baseURL}/enqueue/breadcrumb-job`);
+
+ await processSpanPromise;
+
+ const errorEventPromise = waitForError('node-bullmq', event => {
+ return event.exception?.values?.[0]?.value === 'Isolation check';
+ });
+
+ await fetch(`${baseURL}/check-isolation`);
+
+ const errorEvent = await errorEventPromise;
+
+ const leakedBreadcrumb = (errorEvent.breadcrumbs || []).find(
+ (b: { message?: string }) => b.message === 'breadcrumb-from-bullmq-processor',
+ );
+ expect(leakedBreadcrumb).toBeUndefined();
+});
+
+test('Links the queue.process segment span to its producer span', async ({ baseURL }) => {
+ const producerSpanPromise = waitForStreamedSpan('node-bullmq', span => {
+ return getSpanOp(span) === 'queue.publish' && span.attributes['messaging.bullmq.job.name']?.value === 'link-job';
+ });
+
+ const consumerSpanPromise = waitForStreamedSpan('node-bullmq', span => {
+ return (
+ span.is_segment &&
+ getSpanOp(span) === 'queue.process' &&
+ span.attributes['messaging.bullmq.job.name']?.value === 'link-job'
+ );
+ });
+
+ await fetch(`${baseURL}/enqueue/link-job`);
+
+ const producerSpan = await producerSpanPromise;
+ const consumerSpan = await consumerSpanPromise;
+
+ expect(producerSpan.name).toBe('add test-queue.link-job');
+ expect(consumerSpan.name).toBe('process test-queue');
+ expect(consumerSpan.trace_id).not.toBe(producerSpan.trace_id);
+ expect(consumerSpan.links).toEqual([
+ {
+ trace_id: producerSpan.trace_id,
+ span_id: producerSpan.span_id,
+ sampled: true,
+ attributes: { 'sentry.link.type': { type: 'string', value: 'message_origin' } },
+ },
+ ]);
+});
+
+test('Emits messaging.bullmq.jobs.completed counter metric on successful job', async ({ baseURL }) => {
+ const metricPromise = waitForMetric('node-bullmq', metric => {
+ return (
+ metric.name === 'messaging.bullmq.jobs.completed' &&
+ metric.attributes?.['messaging.bullmq.job.name']?.value === 'counter-job'
+ );
+ });
+
+ await fetch(`${baseURL}/enqueue/counter-job`);
+
+ const metric = await metricPromise;
+
+ expect({
+ type: metric.type,
+ unit: metric.unit,
+ value: metric.value,
+ attributes: metric.attributes,
+ }).toEqual({
+ type: 'counter',
+ unit: 'none',
+ value: 1,
+ attributes: {
+ 'messaging.destination.name': { type: 'string', value: 'test-queue' },
+ 'messaging.bullmq.job.name': { type: 'string', value: 'counter-job' },
+ 'messaging.bullmq.job.state': { type: 'string', value: 'completed' },
+ 'sentry.release': { type: 'string', value: '1.0' },
+ 'sentry.sdk.name': { type: 'string', value: 'sentry.javascript.node' },
+ 'sentry.sdk.version': { type: 'string', value: expect.any(String) },
+ 'sentry.timestamp.sequence': { type: 'integer', value: expect.any(Number) },
+ 'server.address': { type: 'string', value: expect.any(String) },
+ },
+ });
+});
+
+test('Emits messaging.process.duration histogram metric on job completion', async ({ baseURL }) => {
+ const metricPromise = waitForMetric('node-bullmq', metric => {
+ return (
+ metric.name === 'messaging.process.duration' &&
+ metric.attributes?.['messaging.bullmq.job.name']?.value === 'duration-job'
+ );
+ });
+
+ await fetch(`${baseURL}/enqueue/duration-job`);
+
+ const metric = await metricPromise;
+
+ expect({
+ type: metric.type,
+ unit: metric.unit,
+ value: metric.value,
+ attributes: metric.attributes,
+ }).toEqual({
+ type: 'distribution',
+ unit: 'second',
+ value: expect.any(Number),
+ attributes: {
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.operation.name': { type: 'string', value: 'process' },
+ 'messaging.destination.name': { type: 'string', value: 'test-queue' },
+ 'messaging.bullmq.job.name': { type: 'string', value: 'duration-job' },
+ 'messaging.bullmq.job.state': { type: 'string', value: 'completed' },
+ 'sentry.release': { type: 'string', value: '1.0' },
+ 'sentry.sdk.name': { type: 'string', value: 'sentry.javascript.node' },
+ 'sentry.sdk.version': { type: 'string', value: expect.any(String) },
+ 'sentry.timestamp.sequence': { type: 'integer', value: expect.any(Number) },
+ 'server.address': { type: 'string', value: expect.any(String) },
+ },
+ });
+});
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 bf4389573a07..3b45ee182d4e 100644
--- a/dev-packages/e2e-tests/test-applications/node-eve/package.json
+++ b/dev-packages/e2e-tests/test-applications/node-eve/package.json
@@ -32,7 +32,7 @@
"devDependencies": {
"@playwright/test": "1.64.1",
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz",
- "@sentry/conventions": "0.29.0",
+ "@sentry/conventions": "0.30.0",
"@sentry/core": "file:../../packed/sentry-core-packed.tgz",
"@types/node": "24.x",
"typescript": "~5.9.0"
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 6bee27e997bc..446589a1db0c 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
@@ -51,6 +51,7 @@ const DEPENDENTS: Dependent[] = [
exports: Object.keys(SentryBun),
ignoreExports: [
// not supported in bun:
+ 'awsLambdaWebIntegration',
'NodeClient',
'NODE_VERSION',
'childProcessIntegration',
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
deleted file mode 100644
index 2685f6ec088a..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/.gitignore
+++ /dev/null
@@ -1,7 +0,0 @@
-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
deleted file mode 100644
index a31c8523a9a7..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/flue.config.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-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
deleted file mode 100644
index 409652d5c19e..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/package.json
+++ /dev/null
@@ -1,49 +0,0 @@
-{
- "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
deleted file mode 100644
index 161e4b1c5d72..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/playwright.config.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-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
deleted file mode 100644
index 0e523a16e6f0..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/pnpm-workspace.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-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
deleted file mode 100644
index 30f0b4c8cd49..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/sentry-init.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-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
deleted file mode 100644
index 9d218454a86e..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/src/agents/hello.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-'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
deleted file mode 100644
index b54a3357e4ca..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/src/app.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-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
deleted file mode 100644
index ba6a62fc8f7d..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/tests/dataloader.test.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-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
deleted file mode 100644
index aa4666c1a3bf..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/tests/errors.test.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-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
deleted file mode 100644
index 6610ad009546..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/tests/flue.test.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-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
deleted file mode 100644
index 7b3f2814266f..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/tests/utils.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-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
deleted file mode 100644
index 2cb98b13c9f8..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/tsconfig.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "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
deleted file mode 100644
index a77574b64290..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-flue-send-to-sentry/vite.config.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-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-hapi/package.json b/dev-packages/e2e-tests/test-applications/node-hapi/package.json
index 2b0a3b370e22..8a7725dad0b5 100644
--- a/dev-packages/e2e-tests/test-applications/node-hapi/package.json
+++ b/dev-packages/e2e-tests/test-applications/node-hapi/package.json
@@ -17,7 +17,7 @@
},
"devDependencies": {
"@playwright/test": "1.64.1",
- "@sentry/conventions": "0.29.0",
+ "@sentry/conventions": "0.30.0",
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz"
}
}
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 e17539bcbe70..b24a01edc9be 100644
--- a/dev-packages/e2e-tests/test-applications/node-mastra/package.json
+++ b/dev-packages/e2e-tests/test-applications/node-mastra/package.json
@@ -29,7 +29,7 @@
"@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.29.0",
+ "@sentry/conventions": "0.30.0",
"@types/node": "24.x",
"mastra": "~1.28.0",
"typescript": "~5.9.0"
diff --git a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/.gitignore b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/.gitignore
deleted file mode 100644
index 795f6b6d40d1..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/.gitignore
+++ /dev/null
@@ -1,5 +0,0 @@
-node_modules
-pnpm-lock.yaml
-dist
-test-results
-playwright-report
diff --git a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/playwright.config.ts b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/playwright.config.ts
deleted file mode 100644
index af5ea8f0b951..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/playwright.config.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { defineConfig } from '@playwright/test';
-
-const port = 3030;
-
-export default defineConfig({
- testDir: './tests',
- /*
- * Each Sentry poll can take up to 180s. The first poll also waits the ~2min until the span can be
- * queried, so the later polls usually pass at once. This leaves room for two full polls.
- */
- 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-openai-decisions-send-to-sentry/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/pnpm-workspace.yaml
deleted file mode 100644
index a1c031fc3310..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/pnpm-workspace.yaml
+++ /dev/null
@@ -1 +0,0 @@
-minimumReleaseAge: 0
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
deleted file mode 100644
index f15dec0a9e8d..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/tests/send-to-sentry.test.ts
+++ /dev/null
@@ -1,224 +0,0 @@
-import { expect, test } from '@playwright/test';
-import {
- SEARCH_GEN_AI__INPUT__MESSAGES,
- SEARCH_GEN_AI__OPERATION__NAME,
- SEARCH_GEN_AI__OUTPUT__MESSAGES,
- SEARCH_GEN_AI__PROVIDER__NAME,
- SEARCH_GEN_AI__REQUEST__MODEL,
- SEARCH_GEN_AI__RESPONSE__MODEL,
- SEARCH_GEN_AI__USAGE__INPUT_TOKENS,
- SEARCH_GEN_AI__USAGE__OUTPUT_TOKENS,
- SEARCH_GEN_AI__USAGE__TOTAL_TOKENS,
- SEARCH_ORIGIN,
- SEARCH_SPAN__OP,
- SEARCH_SPAN__STATUS,
-} from '@sentry/conventions/attributes/search';
-import { GEN_AI_DECIDE, HTTP_SERVER } from '@sentry/conventions/op';
-import {
- EVENT_POLLING_OPTIONS,
- fetchSpanAttributes,
- fetchTrace,
- findSpanInTrace,
- flattenTrace,
- traceTarget,
-} from '@sentry-internal/test-utils/cli';
-
-// Mirrors src/app.ts.
-const MODEL = 'gpt-6-luna';
-
-/**
- * What every decide span carries once Sentry has stored it, whatever the model answers. The keys are the
- * search names, e.g. `span.op` and `origin` rather than the `sentry.*` attributes the SDK sends.
- * `span.description` stays empty: ingest only derives it for ops it knows, and `gen_ai.decide` is new.
- */
-const DECIDE_SPAN_ATTRIBUTES = {
- 'span.name': `decide ${MODEL}`,
- [SEARCH_SPAN__OP]: GEN_AI_DECIDE,
- [SEARCH_SPAN__STATUS]: 'ok',
- [SEARCH_ORIGIN]: 'auto.ai.openai',
- [SEARCH_GEN_AI__OPERATION__NAME]: 'decide',
- [SEARCH_GEN_AI__PROVIDER__NAME]: 'openai',
- [SEARCH_GEN_AI__REQUEST__MODEL]: MODEL,
- [SEARCH_GEN_AI__RESPONSE__MODEL]: MODEL,
-};
-
-test('records a text decision with its state, questions and answers', async ({ baseURL }) => {
- const response = await fetch(`${baseURL}/decide`);
- const body = await response.json();
- expect(response.status, JSON.stringify(body)).toBe(200);
- const { traceId }: { traceId: string } = body;
-
- console.log(`Polling for gen_ai.decide span: sentry trace view ${traceTarget(traceId)}`);
-
- let spanId: string | undefined;
- await expect
- .poll(() => (spanId = findSpanInTrace(traceId, GEN_AI_DECIDE)?.event_id), EVENT_POLLING_OPTIONS)
- .toBeDefined();
-
- let attributes: Record | undefined;
- await expect
- .poll(() => (attributes = fetchSpanAttributes(traceId, spanId!)), EVENT_POLLING_OPTIONS)
- .toMatchObject(DECIDE_SPAN_ATTRIBUTES);
-
- // Integers come back as strings. Decisions bills input tokens only, so there are no output tokens.
- expect(Number(attributes![SEARCH_GEN_AI__USAGE__OUTPUT_TOKENS])).toBe(0);
- expect(Number(attributes![SEARCH_GEN_AI__USAGE__INPUT_TOKENS])).toBeGreaterThan(0);
- expect(Number(attributes![SEARCH_GEN_AI__USAGE__TOTAL_TOKENS])).toBe(
- Number(attributes![SEARCH_GEN_AI__USAGE__INPUT_TOKENS]),
- );
-
- // The request span is the segment; it ends last, so it can land after the decide span.
- await expect
- .poll(() => {
- const requestSpan = flattenTrace(fetchTrace(traceId)).find(item => item.op === HTTP_SERVER);
- return flattenTrace(requestSpan?.children ?? []).some(item => item.event_id === spanId);
- }, EVENT_POLLING_OPTIONS)
- .toBe(true);
-
- expect(JSON.parse(attributes![SEARCH_GEN_AI__INPUT__MESSAGES] as string)).toEqual([
- {
- type: 'evaluation',
- state: 'I was charged twice this month and want a refund.',
- questions: {
- wants_refund: { type: 'predicate', instructions: 'Is a refund requested?' },
- department: {
- type: 'choice',
- instructions: 'Which team should handle this?',
- criteria: { billing: 'Charges and refunds', other: 'Anything else' },
- },
- urgency: {
- type: 'score',
- instructions: 'How urgent is this ticket?',
- criteria: ['low', 'high'],
- },
- },
- },
- ]);
-
- // The answers depend on the model, the shape does not.
- expect(JSON.parse(attributes![SEARCH_GEN_AI__OUTPUT__MESSAGES] as string)).toEqual([
- {
- type: 'evaluation',
- answers: {
- wants_refund: { type: 'predicate', probability: expect.any(Number) },
- department: {
- type: 'choice',
- choice: expect.stringMatching(/^(billing|other)$/),
- probabilities: { billing: expect.any(Number), other: expect.any(Number) },
- confidence: expect.any(Number),
- },
- urgency: {
- type: 'score',
- score: expect.any(Number),
- probabilities: { 0: expect.any(Number), 1: expect.any(Number) },
- legend: { 0: 'low', 1: 'high' },
- confidence: expect.any(Number),
- },
- },
- },
- ]);
-});
-
-test('records an image decision with its image data', async ({ baseURL }) => {
- const response = await fetch(`${baseURL}/decide-image`);
- const body = await response.json();
- expect(response.status, JSON.stringify(body)).toBe(200);
- const { traceId }: { traceId: string } = body;
-
- console.log(`Polling for gen_ai.decide span: sentry trace view ${traceTarget(traceId)}`);
-
- let spanId: string | undefined;
- await expect
- .poll(() => (spanId = findSpanInTrace(traceId, GEN_AI_DECIDE)?.event_id), EVENT_POLLING_OPTIONS)
- .toBeDefined();
-
- let attributes: Record | undefined;
- await expect
- .poll(() => (attributes = fetchSpanAttributes(traceId, spanId!)), EVENT_POLLING_OPTIONS)
- .toMatchObject(DECIDE_SPAN_ATTRIBUTES);
-
- expect(JSON.parse(attributes![SEARCH_GEN_AI__INPUT__MESSAGES] as string)).toEqual([
- {
- type: 'evaluation',
- state: [
- {
- role: 'user',
- content: [
- { type: 'input_text', text: 'Is this image mostly red?' },
- { type: 'input_image', image_url: expect.stringMatching(/^data:image\/png;base64,/) },
- ],
- },
- ],
- questions: { is_red: { type: 'predicate', instructions: 'Is the image mostly red?' } },
- },
- ]);
-
- expect(JSON.parse(attributes![SEARCH_GEN_AI__OUTPUT__MESSAGES] as string)).toEqual([
- { type: 'evaluation', answers: { is_red: { type: 'predicate', probability: expect.any(Number) } } },
- ]);
-});
-
-test('records a Vercel AI decision with the confidence of its answers', async ({ baseURL }) => {
- const response = await fetch(`${baseURL}/vercel-ai-decide`);
- const body = await response.json();
- expect(response.status, JSON.stringify(body)).toBe(200);
- const { traceId }: { traceId: string } = body;
-
- console.log(`Polling for gen_ai.decide span: sentry trace view ${traceTarget(traceId)}`);
-
- let spanId: string | undefined;
- await expect
- .poll(() => (spanId = findSpanInTrace(traceId, GEN_AI_DECIDE)?.event_id), EVENT_POLLING_OPTIONS)
- .toBeDefined();
-
- let attributes: Record | undefined;
- await expect
- .poll(() => (attributes = fetchSpanAttributes(traceId, spanId!)), EVENT_POLLING_OPTIONS)
- .toMatchObject({
- 'span.name': `decide ${MODEL}`,
- [SEARCH_SPAN__OP]: GEN_AI_DECIDE,
- [SEARCH_SPAN__STATUS]: 'ok',
- [SEARCH_ORIGIN]: 'auto.ai.vercelai',
- [SEARCH_GEN_AI__OPERATION__NAME]: 'decide',
- [SEARCH_GEN_AI__PROVIDER__NAME]: 'openai.decision',
- [SEARCH_GEN_AI__REQUEST__MODEL]: MODEL,
- });
-
- expect(JSON.parse(attributes![SEARCH_GEN_AI__INPUT__MESSAGES] as string)).toEqual([
- {
- type: 'evaluation',
- state: 'I was charged twice this month and want a refund.',
- questions: {
- wantsRefund: { type: 'boolean', instructions: 'Is a refund requested?' },
- department: {
- type: 'choice',
- instructions: 'Which team should handle this?',
- criteria: { billing: 'Charges and refunds', other: 'Anything else' },
- },
- urgency: { type: 'score', instructions: 'How urgent is this ticket?', criteria: ['low', 'high'] },
- },
- },
- ]);
-
- // The OpenAI provider moves `confidence` into its provider metadata; the span must keep it on the answers.
- expect(JSON.parse(attributes![SEARCH_GEN_AI__OUTPUT__MESSAGES] as string)).toEqual([
- {
- type: 'evaluation',
- answers: {
- wantsRefund: { type: 'boolean', probability: expect.any(Number) },
- department: {
- type: 'choice',
- choice: expect.stringMatching(/^(billing|other)$/),
- probabilities: { billing: expect.any(Number), other: expect.any(Number) },
- confidence: expect.any(Number),
- },
- urgency: {
- type: 'score',
- score: expect.any(Number),
- probabilities: { 0: expect.any(Number), 1: expect.any(Number) },
- confidence: expect.any(Number),
- },
- },
- },
- ]);
-});
diff --git a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/tsconfig.json
deleted file mode 100644
index 795574fd9df2..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/tsconfig.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- "compilerOptions": {
- "target": "es2022",
- "module": "nodenext",
- "moduleResolution": "nodenext",
- "outDir": "dist",
- "strict": true,
- "esModuleInterop": true,
- "skipLibCheck": true,
- "types": ["node"]
- },
- "include": ["src/**/*.ts"]
-}
diff --git a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/.gitignore b/dev-packages/e2e-tests/test-applications/node-openai-decisions/.gitignore
similarity index 100%
rename from dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/.gitignore
rename to dev-packages/e2e-tests/test-applications/node-openai-decisions/.gitignore
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/package.json
similarity index 70%
rename from dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/package.json
rename to dev-packages/e2e-tests/test-applications/node-openai-decisions/package.json
index 5f2855dd6dc1..41a84ca03d45 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/package.json
@@ -1,6 +1,6 @@
{
- "name": "node-openai-decisions-send-to-sentry",
- "description": "The OpenAI Decisions API as a user runs it: a plain Sentry.init on an express app, real decisions requests against OpenAI through the openai client and the Vercel AI SDK, and the gen_ai.decide spans read back from a real Sentry project",
+ "name": "node-openai-decisions",
+ "description": "The OpenAI Decisions API as a user runs it: real decisions requests against OpenAI through the openai client and the Vercel AI SDK, and the gen_ai.decide spans they record",
"version": "1.0.0",
"private": true,
"type": "module",
@@ -24,10 +24,9 @@
"devDependencies": {
"@playwright/test": "1.64.1",
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz",
- "@sentry/conventions": "0.29.0",
+ "@sentry/conventions": "0.30.0",
"@types/express": "^4.17.21",
"@types/node": "^24.0.0",
- "sentry": "~0.44.1",
"typescript": "~5.9.0"
},
"sentryTest": {
@@ -35,7 +34,7 @@
"optionalVariants": [
{
"build-command": "pnpm test:build-latest",
- "label": "node-openai-decisions-send-to-sentry (latest)"
+ "label": "node-openai-decisions (latest)"
}
]
}
diff --git a/dev-packages/e2e-tests/test-applications/node-openai-decisions/playwright.config.ts b/dev-packages/e2e-tests/test-applications/node-openai-decisions/playwright.config.ts
new file mode 100644
index 000000000000..7fe69f2eddd2
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/node-openai-decisions/playwright.config.ts
@@ -0,0 +1,9 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+
+const config = getPlaywrightConfig(
+ { startCommand: 'pnpm start' },
+ // Each test makes a real Decisions API call, which does not reliably fit the default 30s timeout.
+ { timeout: 90_000 },
+);
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/node-openai-decisions/pnpm-workspace.yaml
similarity index 100%
rename from dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/pnpm-workspace.yaml
rename to dev-packages/e2e-tests/test-applications/node-openai-decisions/pnpm-workspace.yaml
diff --git a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/src/app.ts b/dev-packages/e2e-tests/test-applications/node-openai-decisions/src/app.ts
similarity index 94%
rename from dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/src/app.ts
rename to dev-packages/e2e-tests/test-applications/node-openai-decisions/src/app.ts
index 20c7ba9a96c7..4a3f15581765 100644
--- a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/src/app.ts
+++ b/dev-packages/e2e-tests/test-applications/node-openai-decisions/src/app.ts
@@ -23,8 +23,8 @@ const RED_PIXEL =
const app = express();
-// Each route makes one `decisions.create` call and answers with the trace id, so the test can find
-// the request's spans in Sentry.
+// Each route makes one decisions call and answers with the trace id, so the test can check it picked the
+// spans of its own request.
app.get('/decide', async (_req, res, next) => {
try {
@@ -107,5 +107,5 @@ app.use((error: Error, _req: express.Request, res: express.Response, _next: expr
const port = Number(process.env.PORT ?? 3030);
app.listen(port, () => {
// eslint-disable-next-line no-console
- console.log(`node-openai-decisions-send-to-sentry listening on port ${port}`);
+ console.log(`node-openai-decisions listening on port ${port}`);
});
diff --git a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-openai-decisions/src/instrument.ts
similarity index 63%
rename from dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/src/instrument.ts
rename to dev-packages/e2e-tests/test-applications/node-openai-decisions/src/instrument.ts
index e0a0c1807f75..9d6f0f49f765 100644
--- a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/src/instrument.ts
+++ b/dev-packages/e2e-tests/test-applications/node-openai-decisions/src/instrument.ts
@@ -2,6 +2,7 @@ import * as Sentry from '@sentry/node';
Sentry.init({
dsn: process.env.E2E_TEST_DSN,
- environment: 'qa', // dynamic sampling bias to keep transactions
+ environment: 'qa',
+ tunnel: 'http://localhost:3031/',
tracesSampleRate: 1,
});
diff --git a/dev-packages/e2e-tests/test-applications/node-openai-decisions/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-openai-decisions/start-event-proxy.mjs
new file mode 100644
index 000000000000..a40b90989577
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/node-openai-decisions/start-event-proxy.mjs
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'node-openai-decisions',
+});
diff --git a/dev-packages/e2e-tests/test-applications/node-openai-decisions/tests/decisions.test.ts b/dev-packages/e2e-tests/test-applications/node-openai-decisions/tests/decisions.test.ts
new file mode 100644
index 000000000000..2f4647ecbf6e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/node-openai-decisions/tests/decisions.test.ts
@@ -0,0 +1,195 @@
+import { expect, test } from '@playwright/test';
+import {
+ GEN_AI_INPUT_MESSAGES,
+ GEN_AI_OPERATION_NAME,
+ GEN_AI_OUTPUT_MESSAGES,
+ GEN_AI_PROVIDER_NAME,
+ GEN_AI_REQUEST_MODEL,
+ GEN_AI_RESPONSE_MODEL,
+ GEN_AI_USAGE_INPUT_TOKENS,
+ GEN_AI_USAGE_OUTPUT_TOKENS,
+ GEN_AI_USAGE_TOTAL_TOKENS,
+ SENTRY_ORIGIN,
+} from '@sentry/conventions/attributes';
+import { GEN_AI_DECIDE, HTTP_SERVER } from '@sentry/conventions/op';
+import type { SerializedStreamedSpan } from '@sentry-internal/test-utils';
+import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils';
+
+const APP = 'node-openai-decisions';
+
+// Mirrors src/app.ts.
+const MODEL = 'gpt-6-luna';
+
+const attr = (span: SerializedStreamedSpan, key: string): unknown => span.attributes?.[key]?.value;
+
+const isDecideSpan = (span: SerializedStreamedSpan, origin: string): boolean =>
+ getSpanOp(span) === GEN_AI_DECIDE && attr(span, SENTRY_ORIGIN) === origin;
+
+/**
+ * Calls `path` and returns its decide span once the request span (the segment) has arrived as well. The
+ * text and image decisions share an origin, so `inputMarker` tells their spans apart.
+ */
+async function requestDecideSpan(
+ baseURL: string | undefined,
+ path: string,
+ origin: string,
+ inputMarker: string,
+): Promise {
+ const matches = (span: SerializedStreamedSpan): boolean =>
+ isDecideSpan(span, origin) && String(attr(span, GEN_AI_INPUT_MESSAGES)).includes(inputMarker);
+ const spansPromise = collectStreamedSpans(
+ APP,
+ spansOfTrace => spansOfTrace.some(matches) && spansOfTrace.some(span => span.is_segment),
+ );
+
+ const response = await fetch(`${baseURL}${path}`);
+ const body = await response.json();
+ expect(response.status, JSON.stringify(body)).toBe(200);
+
+ const spans = await spansPromise;
+ const decideSpans = spans.filter(span => getSpanOp(span) === GEN_AI_DECIDE);
+ expect(decideSpans).toHaveLength(1);
+ const decideSpan = decideSpans[0]!;
+ expect(decideSpan.trace_id).toBe(body.traceId);
+ expect(getSpanOp(spans.find(span => span.is_segment)!)).toBe(HTTP_SERVER);
+
+ return decideSpan;
+}
+
+/** What every decide span of the `openai` client carries, whatever the model answers. */
+function expectOpenAiDecideSpan(span: SerializedStreamedSpan): void {
+ expect(span.name).toBe(`decide ${MODEL}`);
+ expect(span.status).toBe('ok');
+ expect(attr(span, GEN_AI_OPERATION_NAME)).toBe('decide');
+ expect(attr(span, GEN_AI_PROVIDER_NAME)).toBe('openai');
+ expect(attr(span, GEN_AI_REQUEST_MODEL)).toBe(MODEL);
+ expect(attr(span, GEN_AI_RESPONSE_MODEL)).toBe(MODEL);
+}
+
+test('records a text decision with its state, questions and answers', async ({ baseURL }) => {
+ const span = await requestDecideSpan(baseURL, '/decide', 'auto.ai.openai', 'charged twice');
+
+ expectOpenAiDecideSpan(span);
+
+ // Decisions bills input tokens only, so there are no output tokens.
+ expect(attr(span, GEN_AI_USAGE_OUTPUT_TOKENS)).toBe(0);
+ expect(attr(span, GEN_AI_USAGE_INPUT_TOKENS)).toBeGreaterThan(0);
+ expect(attr(span, GEN_AI_USAGE_TOTAL_TOKENS)).toBe(attr(span, GEN_AI_USAGE_INPUT_TOKENS));
+
+ expect(JSON.parse(attr(span, GEN_AI_INPUT_MESSAGES) as string)).toEqual([
+ {
+ type: 'evaluation',
+ state: 'I was charged twice this month and want a refund.',
+ questions: {
+ wants_refund: { type: 'predicate', instructions: 'Is a refund requested?' },
+ department: {
+ type: 'choice',
+ instructions: 'Which team should handle this?',
+ criteria: { billing: 'Charges and refunds', other: 'Anything else' },
+ },
+ urgency: {
+ type: 'score',
+ instructions: 'How urgent is this ticket?',
+ criteria: ['low', 'high'],
+ },
+ },
+ },
+ ]);
+
+ // The answers depend on the model, the shape does not.
+ expect(JSON.parse(attr(span, GEN_AI_OUTPUT_MESSAGES) as string)).toEqual([
+ {
+ type: 'evaluation',
+ answers: {
+ wants_refund: { type: 'predicate', probability: expect.any(Number) },
+ department: {
+ type: 'choice',
+ choice: expect.stringMatching(/^(billing|other)$/),
+ probabilities: { billing: expect.any(Number), other: expect.any(Number) },
+ confidence: expect.any(Number),
+ },
+ urgency: {
+ type: 'score',
+ score: expect.any(Number),
+ probabilities: { 0: expect.any(Number), 1: expect.any(Number) },
+ legend: { 0: 'low', 1: 'high' },
+ confidence: expect.any(Number),
+ },
+ },
+ },
+ ]);
+});
+
+test('records an image decision with its image data', async ({ baseURL }) => {
+ const span = await requestDecideSpan(baseURL, '/decide-image', 'auto.ai.openai', 'mostly red');
+
+ expectOpenAiDecideSpan(span);
+
+ expect(JSON.parse(attr(span, GEN_AI_INPUT_MESSAGES) as string)).toEqual([
+ {
+ type: 'evaluation',
+ state: [
+ {
+ role: 'user',
+ content: [
+ { type: 'input_text', text: 'Is this image mostly red?' },
+ { type: 'input_image', image_url: expect.stringMatching(/^data:image\/png;base64,/) },
+ ],
+ },
+ ],
+ questions: { is_red: { type: 'predicate', instructions: 'Is the image mostly red?' } },
+ },
+ ]);
+
+ expect(JSON.parse(attr(span, GEN_AI_OUTPUT_MESSAGES) as string)).toEqual([
+ { type: 'evaluation', answers: { is_red: { type: 'predicate', probability: expect.any(Number) } } },
+ ]);
+});
+
+test('records a Vercel AI decision with the confidence of its answers', async ({ baseURL }) => {
+ const span = await requestDecideSpan(baseURL, '/vercel-ai-decide', 'auto.ai.vercelai', 'charged twice');
+
+ expect(span.name).toBe(`decide ${MODEL}`);
+ expect(span.status).toBe('ok');
+ expect(attr(span, GEN_AI_OPERATION_NAME)).toBe('decide');
+ expect(attr(span, GEN_AI_PROVIDER_NAME)).toBe('openai.decision');
+ expect(attr(span, GEN_AI_REQUEST_MODEL)).toBe(MODEL);
+
+ expect(JSON.parse(attr(span, GEN_AI_INPUT_MESSAGES) as string)).toEqual([
+ {
+ type: 'evaluation',
+ state: 'I was charged twice this month and want a refund.',
+ questions: {
+ wantsRefund: { type: 'boolean', instructions: 'Is a refund requested?' },
+ department: {
+ type: 'choice',
+ instructions: 'Which team should handle this?',
+ criteria: { billing: 'Charges and refunds', other: 'Anything else' },
+ },
+ urgency: { type: 'score', instructions: 'How urgent is this ticket?', criteria: ['low', 'high'] },
+ },
+ },
+ ]);
+
+ // The OpenAI provider moves `confidence` into its provider metadata; the span must keep it on the answers.
+ expect(JSON.parse(attr(span, GEN_AI_OUTPUT_MESSAGES) as string)).toEqual([
+ {
+ type: 'evaluation',
+ answers: {
+ wantsRefund: { type: 'boolean', probability: expect.any(Number) },
+ department: {
+ type: 'choice',
+ choice: expect.stringMatching(/^(billing|other)$/),
+ probabilities: { billing: expect.any(Number), other: expect.any(Number) },
+ confidence: expect.any(Number),
+ },
+ urgency: {
+ type: 'score',
+ score: expect.any(Number),
+ probabilities: { 0: expect.any(Number), 1: expect.any(Number) },
+ confidence: expect.any(Number),
+ },
+ },
+ },
+ ]);
+});
diff --git a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-openai-decisions/tsconfig.json
similarity index 100%
rename from dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/tsconfig.json
rename to dev-packages/e2e-tests/test-applications/node-openai-decisions/tsconfig.json
diff --git a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/.gitignore b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/.gitignore
deleted file mode 100644
index 795f6b6d40d1..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/.gitignore
+++ /dev/null
@@ -1,5 +0,0 @@
-node_modules
-pnpm-lock.yaml
-dist
-test-results
-playwright-report
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
deleted file mode 100644
index f94e4d8cf2ba..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/package.json
+++ /dev/null
@@ -1,38 +0,0 @@
-{
- "name": "node-openai-send-to-sentry",
- "description": "The OpenAI integration as a user runs it: a plain Sentry.init on an express app, real chat, streaming, tool-call, Responses API and embeddings requests through OpenRouter, and the gen_ai spans read back from a real Sentry project",
- "version": "1.0.0",
- "private": true,
- "type": "module",
- "scripts": {
- "build": "tsc",
- "start": "node --import ./dist/instrument.js dist/app.js",
- "test": "playwright test",
- "clean": "npx rimraf node_modules dist pnpm-lock.yaml",
- "test:build": "pnpm install && pnpm build",
- "test:build-latest": "pnpm install && pnpm add openai@latest && pnpm build",
- "test:assert": "pnpm test"
- },
- "dependencies": {
- "@sentry/node": "file:../../packed/sentry-node-packed.tgz",
- "express": "^4.21.2",
- "openai": "7.5.0"
- },
- "devDependencies": {
- "@playwright/test": "1.64.1",
- "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz",
- "@types/express": "^4.17.21",
- "@types/node": "^24.0.0",
- "sentry": "~0.44.1",
- "typescript": "~5.9.0"
- },
- "sentryTest": {
- "optional": true,
- "optionalVariants": [
- {
- "build-command": "pnpm test:build-latest",
- "label": "node-openai-send-to-sentry (latest)"
- }
- ]
- }
-}
diff --git a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/playwright.config.ts b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/playwright.config.ts
deleted file mode 100644
index e7621a23cc8b..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/playwright.config.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { defineConfig } from '@playwright/test';
-
-const port = 3030;
-
-export default defineConfig({
- testDir: './tests',
- /* Each test runs two Sentry polls of up to 180s each, after a live model call. */
- 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-openai-send-to-sentry/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/pnpm-workspace.yaml
deleted file mode 100644
index a1c031fc3310..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/pnpm-workspace.yaml
+++ /dev/null
@@ -1 +0,0 @@
-minimumReleaseAge: 0
diff --git a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/src/app.ts b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/src/app.ts
deleted file mode 100644
index ac99c6d18eeb..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/src/app.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-// `instrument.ts` is preloaded via `node --import`, so Sentry is already initialised here.
-import * as Sentry from '@sentry/node';
-import express from 'express';
-import OpenAI from 'openai';
-
-const apiKey = process.env.E2E_OPENROUTER_API_KEY;
-if (!apiKey) {
- throw new Error('E2E_OPENROUTER_API_KEY is not set');
-}
-
-// OpenRouter serves the OpenAI API shape (chat completions, responses, embeddings), so the stock
-// client only needs a different base URL. The model is incidental: what is under test is the SDK's own
-// request/response code path, which is what Sentry instruments.
-const client = new OpenAI({ apiKey, baseURL: 'https://openrouter.ai/api/v1' });
-
-const CHAT_MODEL = 'openai/gpt-4o-mini';
-const EMBEDDINGS_MODEL = 'openai/text-embedding-3-small';
-
-const SHORT_ANSWER = 'Answer in at most five words.';
-const CHAT_PROMPT = `What is the capital of France? ${SHORT_ANSWER}`;
-// Deliberately does not name the tool: `tool_choice: 'required'` forces the call.
-const WEATHER_PROMPT = `What is the weather in Paris? ${SHORT_ANSWER}`;
-const SYSTEM = 'You are a helpful assistant used by an automated test.';
-
-const WEATHER_TOOL = {
- type: 'function' as const,
- function: {
- name: 'get_weather',
- description: 'Get the current weather for a city.',
- parameters: {
- type: 'object',
- properties: { city: { type: 'string', description: 'The city name' } },
- required: ['city'],
- },
- },
-};
-
-const app = express();
-
-/** The trace this request is recorded under, so the test can look its spans up in Sentry. */
-function currentTraceId(): string | undefined {
- return Sentry.getActiveSpan()?.spanContext().traceId;
-}
-
-// Each route makes one call through the `openai` client and answers with the trace id, so the test can
-// find the request's spans in Sentry.
-
-app.get('/chat', async (_req, res, next) => {
- try {
- const completion = await client.chat.completions.create({
- model: CHAT_MODEL,
- messages: [
- { role: 'system', content: SYSTEM },
- { role: 'user', content: CHAT_PROMPT },
- ],
- temperature: 0,
- max_tokens: 32,
- });
- res.send({
- traceId: currentTraceId(),
- answer: completion.choices[0]?.message.content ?? '',
- });
- } catch (error) {
- next(error);
- }
-});
-
-app.get('/chat-stream', async (_req, res, next) => {
- try {
- const stream = await client.chat.completions.create({
- model: CHAT_MODEL,
- messages: [
- { role: 'system', content: SYSTEM },
- { role: 'user', content: CHAT_PROMPT },
- ],
- temperature: 0,
- max_tokens: 32,
- stream: true,
- // Ask for the usage in the final chunk, so the streamed span carries token counts.
- stream_options: { include_usage: true },
- });
-
- let answer = '';
- for await (const chunk of stream) {
- answer += chunk.choices[0]?.delta.content ?? '';
- }
- res.send({ traceId: currentTraceId(), answer });
- } catch (error) {
- next(error);
- }
-});
-
-app.get('/tools', async (_req, res, next) => {
- try {
- const completion = await client.chat.completions.create({
- model: CHAT_MODEL,
- messages: [{ role: 'user', content: WEATHER_PROMPT }],
- tools: [WEATHER_TOOL],
- tool_choice: 'required',
- max_tokens: 64,
- });
- res.send({
- traceId: currentTraceId(),
- toolCalls: completion.choices[0]?.message.tool_calls ?? [],
- });
- } catch (error) {
- next(error);
- }
-});
-
-app.get('/responses', async (_req, res, next) => {
- try {
- const response = await client.responses.create({
- model: CHAT_MODEL,
- instructions: SYSTEM,
- input: CHAT_PROMPT,
- temperature: 0,
- max_output_tokens: 32,
- });
- res.send({ traceId: currentTraceId(), answer: response.output_text });
- } catch (error) {
- next(error);
- }
-});
-
-app.get('/embeddings', async (_req, res, next) => {
- try {
- const embedding = await client.embeddings.create({
- model: EMBEDDINGS_MODEL,
- input: 'The capital of France is Paris.',
- });
- res.send({
- traceId: currentTraceId(),
- dimensions: embedding.data[0]?.embedding.length ?? 0,
- });
- } catch (error) {
- next(error);
- }
-});
-
-Sentry.setupExpressErrorHandler(app);
-
-app.use((error: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
- res.status(500).send({ message: error.message });
-});
-
-const port = Number(process.env.PORT ?? 3030);
-app.listen(port, () => {
- // eslint-disable-next-line no-console
- console.log(`node-openai-send-to-sentry listening on port ${port}`);
-});
diff --git a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/src/instrument.ts
deleted file mode 100644
index c6c15df960cb..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/src/instrument.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import * as Sentry from '@sentry/node';
-
-// The setup from the docs, nothing more: preloaded with `node --import`, no tunnel, so the spans go to
-// the real Sentry project behind `E2E_TEST_DSN`, where the tests read them back. The OpenAI integration
-// is on by default; its runtime channel injection is what picks up the `openai` client in app.ts.
-Sentry.init({
- dsn: process.env.E2E_TEST_DSN,
- environment: 'qa', // dynamic sampling bias to keep transactions
- tracesSampleRate: 1,
-});
diff --git a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/tests/send-to-sentry.test.ts b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/tests/send-to-sentry.test.ts
deleted file mode 100644
index eee3e75aeafb..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/tests/send-to-sentry.test.ts
+++ /dev/null
@@ -1,172 +0,0 @@
-import { expect, test } from '@playwright/test';
-import type { TraceItem } from '@sentry-internal/test-utils/cli';
-import {
- EVENT_POLLING_OPTIONS,
- fetchSpanAttributes,
- fetchTrace,
- findSpanInTrace,
- flattenTrace,
-} from '@sentry-internal/test-utils/cli';
-
-// Mirrors src/app.ts.
-const CHAT_MODEL = 'openai/gpt-4o-mini';
-const EMBEDDINGS_MODEL = 'openai/text-embedding-3-small';
-
-type Attributes = Record;
-
-/** Sends one request to the app and returns the trace its request span was recorded under. */
-async function requestTrace(baseURL: string | undefined, path: string): Promise {
- const response = await fetch(`${baseURL}${path}`);
- const body = await response.text();
- expect(response.status, `${path} answered ${response.status}: ${body}`).toBe(200);
-
- const { traceId } = JSON.parse(body) as { traceId?: string };
- expect(traceId, `${path} did not report a trace id: ${body}`).toMatch(/^[0-9a-f]{32}$/);
-
- console.log(`${path}: https://${process.env.E2E_TEST_SENTRY_ORG_SLUG}.sentry.io/explore/traces/trace/${traceId}/`);
- return traceId!;
-}
-
-/**
- * Polls Sentry until the trace holds a span with `op`, then returns that span and its attributes. The
- * trace endpoint only carries the span's op, name and place in the tree; the attributes need a second
- * lookup, which is polled as well since the two can land apart.
- */
-async function waitForSpan(traceId: string, op: string): Promise<{ span: TraceItem; attributes: Attributes }> {
- let found: { span: TraceItem; attributes: Attributes } | undefined;
-
- await expect
- .poll(() => {
- const span = findSpanInTrace(traceId, op);
- const attributes = span?.event_id ? fetchSpanAttributes(traceId, span.event_id) : undefined;
- found = span && attributes ? { span, attributes } : undefined;
- return found;
- }, EVENT_POLLING_OPTIONS)
- .toBeDefined();
-
- return found!;
-}
-
-/** The ids of the spans with `op` below the request span (`http.server`) of the trace. */
-function spanIdsUnderRequestSpan(traceId: string, op: string): string[] {
- const requestSpan = flattenTrace(fetchTrace(traceId)).find(item => item.op === 'http.server');
- return flattenTrace(requestSpan?.children ?? [])
- .filter(item => item.op === op)
- .map(item => item.event_id!);
-}
-
-/**
- * The request span is the segment; it ends last, so it can land after its children. Expecting exactly
- * one span also catches the call being instrumented twice.
- */
-async function expectOnlySpanUnderRequestSpan(traceId: string, op: string, span: TraceItem): Promise {
- await expect.poll(() => spanIdsUnderRequestSpan(traceId, op), EVENT_POLLING_OPTIONS).toEqual([span.event_id]);
-}
-
-/**
- * The attributes every successful model-call span carries once Sentry has stored it, whatever the
- * model answers. Model-dependent values (token counts, response id and model) are checked for shape,
- * not content. Note the names are the stored ones: `origin` and `span.status` rather than the
- * `sentry.*` attributes the SDK sends.
- */
-function expectModelCallAttributes(attributes: Attributes, operation: 'chat' | 'embeddings', model: string): void {
- expect(attributes).toMatchObject({
- 'span.op': `gen_ai.${operation}`,
- 'span.status': 'ok',
- origin: 'auto.ai.openai',
- 'gen_ai.operation.name': operation,
- 'gen_ai.provider.name': 'openai',
- 'gen_ai.request.model': model,
- });
- // OpenAI embeddings responses carry no id, so only chat calls are guaranteed one.
- if (operation === 'chat') {
- expect(typeof attributes['gen_ai.response.id']).toBe('string');
- }
- expect(typeof attributes['gen_ai.response.model']).toBe('string');
- // The Sentry API returns int attributes as strings.
- expect(Number(attributes['gen_ai.usage.input_tokens'])).toBeGreaterThan(0);
- expect(Number(attributes['gen_ai.usage.total_tokens'])).toBeGreaterThan(0);
-}
-
-/** `gen_ai.response.finish_reasons` is a JSON array; a streamed answer can repeat the reason per chunk. */
-function finishReasons(attributes: Attributes): string[] {
- return JSON.parse(attributes['gen_ai.response.finish_reasons'] as string);
-}
-
-test('Sends a chat completion to Sentry as a gen_ai.chat span under the request span', async ({ baseURL }) => {
- const traceId = await requestTrace(baseURL, '/chat');
-
- const { span, attributes } = await waitForSpan(traceId, 'gen_ai.chat');
-
- expect(span.description).toBe(`chat ${CHAT_MODEL}`);
- expectModelCallAttributes(attributes, 'chat', CHAT_MODEL);
- expect(Number(attributes['gen_ai.usage.output_tokens'])).toBeGreaterThan(0);
- expect(finishReasons(attributes)).toEqual(['stop']);
- expect(attributes['gen_ai.response.streaming']).toBeUndefined();
-
- // The SDK records prompts and answers by default, and they have to survive the trip to Sentry. The
- // answer is sent as `gen_ai.response.text` and stored as `gen_ai.output.messages`.
- expect(attributes['gen_ai.system_instructions']).toContain('automated test');
- expect(attributes['gen_ai.input.messages']).toContain('capital of France');
- expect(attributes['gen_ai.output.messages']).toMatch(/paris/i);
-
- await expectOnlySpanUnderRequestSpan(traceId, 'gen_ai.chat', span);
-});
-
-test('Sends a streamed chat completion to Sentry with its token usage', async ({ baseURL }) => {
- const traceId = await requestTrace(baseURL, '/chat-stream');
-
- const { span, attributes } = await waitForSpan(traceId, 'gen_ai.chat');
-
- expect(span.description).toBe(`chat ${CHAT_MODEL}`);
- expectModelCallAttributes(attributes, 'chat', CHAT_MODEL);
- expect(attributes['gen_ai.response.streaming']).toBe(true);
- // The usage only arrives in the final chunk, which the streaming instrumentation has to pick up.
- expect(Number(attributes['gen_ai.usage.output_tokens'])).toBeGreaterThan(0);
- expect(finishReasons(attributes)).toContain('stop');
- expect(attributes['gen_ai.output.messages']).toMatch(/paris/i);
-
- await expectOnlySpanUnderRequestSpan(traceId, 'gen_ai.chat', span);
-});
-
-test('Sends a chat completion that returned a tool call to Sentry', async ({ baseURL }) => {
- const traceId = await requestTrace(baseURL, '/tools');
-
- const { span, attributes } = await waitForSpan(traceId, 'gen_ai.chat');
-
- expectModelCallAttributes(attributes, 'chat', CHAT_MODEL);
- expect(finishReasons(attributes)).toEqual(['tool_calls']);
- expect(attributes['gen_ai.tool.definitions']).toContain('get_weather');
- // The SDK also sends the returned call as `gen_ai.response.tool_calls`, but that attribute is not
- // stored as such, so the finish reason is what proves the tool call made it.
- // TODO: Assert the call in `gen_ai.output.messages` once the OpenAI integration records it there.
-
- await expectOnlySpanUnderRequestSpan(traceId, 'gen_ai.chat', span);
-});
-
-test('Sends a Responses API call to Sentry as a gen_ai.chat span', async ({ baseURL }) => {
- const traceId = await requestTrace(baseURL, '/responses');
-
- const { span, attributes } = await waitForSpan(traceId, 'gen_ai.chat');
-
- expect(span.description).toBe(`chat ${CHAT_MODEL}`);
- expectModelCallAttributes(attributes, 'chat', CHAT_MODEL);
- expect(Number(attributes['gen_ai.usage.output_tokens'])).toBeGreaterThan(0);
- expect(finishReasons(attributes)).toEqual(['completed']);
- expect(attributes['gen_ai.input.messages']).toContain('capital of France');
- expect(attributes['gen_ai.output.messages']).toMatch(/paris/i);
-
- await expectOnlySpanUnderRequestSpan(traceId, 'gen_ai.chat', span);
-});
-
-test('Sends an embeddings call to Sentry as a gen_ai.embeddings span', async ({ baseURL }) => {
- const traceId = await requestTrace(baseURL, '/embeddings');
-
- const { span, attributes } = await waitForSpan(traceId, 'gen_ai.embeddings');
-
- expect(span.description).toBe(`embeddings ${EMBEDDINGS_MODEL}`);
- expectModelCallAttributes(attributes, 'embeddings', EMBEDDINGS_MODEL);
- expect(attributes['gen_ai.embeddings.input']).toContain('Paris');
-
- await expectOnlySpanUnderRequestSpan(traceId, 'gen_ai.embeddings', span);
-});
diff --git a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/tsconfig.json
deleted file mode 100644
index 795574fd9df2..000000000000
--- a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/tsconfig.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- "compilerOptions": {
- "target": "es2022",
- "module": "nodenext",
- "moduleResolution": "nodenext",
- "outDir": "dist",
- "strict": true,
- "esModuleInterop": true,
- "skipLibCheck": true,
- "types": ["node"]
- },
- "include": ["src/**/*.ts"]
-}
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 423983c0ca7b..7f21e622f083 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
@@ -14,12 +14,13 @@
"@earendil-works/chord": "1.2.0",
"@earendil-works/pi-ai": "1.2.0",
"@earendil-works/pi-durable": "1.2.0",
- "@sentry/node": "file:../../packed/sentry-node-packed.tgz"
+ "@sentry/node": "file:../../packed/sentry-node-packed.tgz",
+ "typebox": "1.3.27"
},
"devDependencies": {
"@playwright/test": "1.64.1",
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz",
- "@sentry/conventions": "0.29.0",
+ "@sentry/conventions": "0.30.0",
"@sentry/core": "file:../../packed/sentry-core-packed.tgz",
"@types/node": "24.x"
},
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 093eaf74af5a..26e70d9dca8c 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
@@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync } from 'node:fs';
import { createServer } from 'node:http';
import { fileURLToPath } from 'node:url';
import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context';
-import { Type } from '@earendil-works/pi-ai';
+import { Type } from 'typebox';
import { createModels } from '@earendil-works/pi-ai/models';
import { openrouterProvider } from '@earendil-works/pi-ai/providers/openrouter';
import {
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 a1fe29e4acc4..60b06db8c87f 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
@@ -24,8 +24,5 @@
"devDependencies": {
"@playwright/test": "1.64.1",
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz"
- },
- "sentryTest": {
- "runner": "namespace-profile-sentry-javascript"
}
}
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 d1891d751fc7..beb754c4e7c4 100644
--- a/dev-packages/e2e-tests/test-applications/nuxt-3/package.json
+++ b/dev-packages/e2e-tests/test-applications/nuxt-3/package.json
@@ -25,7 +25,7 @@
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz"
},
"sentryTest": {
- "optionalVariants": [
+ "canaryVariants": [
{
"build-command": "pnpm test:build-canary",
"label": "nuxt-3 (canary)"
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 e21627e76f08..f62cc242e350 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,11 +26,10 @@
},
"devDependencies": {
"@playwright/test": "1.64.1",
- "@sentry/conventions": "0.29.0",
+ "@sentry/conventions": "0.30.0",
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz"
},
"sentryTest": {
- "runner": "namespace-profile-sentry-javascript",
"variants": [
{
"build-command": "E2E_TEST_OTEL_SETUP=true pnpm test:build",
@@ -38,7 +37,7 @@
"label": "nuxt-4-static (tracer provider)"
}
],
- "optionalVariants": [
+ "canaryVariants": [
{
"build-command": "pnpm test:build-canary",
"label": "nuxt-4-static (canary)"
diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/docker-compose.yml b/dev-packages/e2e-tests/test-applications/nuxt-4/docker-compose.yml
index bcb384119acb..a8b2856402a5 100644
--- a/dev-packages/e2e-tests/test-applications/nuxt-4/docker-compose.yml
+++ b/dev-packages/e2e-tests/test-applications/nuxt-4/docker-compose.yml
@@ -18,8 +18,8 @@ services:
start_period: 10s
mongo:
- # `mongo:8` floats to the newest 8.x release, which keeps failing its healthcheck on CI.
- image: mongo:8.0
+ # MongoDB 8 does not start on Linux kernel 6.19 to 7.0.13 (SERVER-121912), which GitHub-hosted runners use.
+ image: mongo:7.0
restart: always
container_name: e2e-tests-nuxt-4-mongo
ports:
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 abfe18e43daa..35f5a25b7a42 100644
--- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json
+++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json
@@ -35,7 +35,6 @@
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz"
},
"sentryTest": {
- "runner": "namespace-profile-sentry-javascript",
"variants": [
{
"build-command": "E2E_TEST_OTEL_SETUP=true pnpm test:build",
@@ -63,7 +62,7 @@
"label": "nuxt-4 (tunnel-basepath-dev)"
}
],
- "optionalVariants": [
+ "canaryVariants": [
{
"build-command": "pnpm test:build-canary",
"label": "nuxt-4 (canary)"
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 8c64d688098f..4dc1e34c400a 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
@@ -3,7 +3,6 @@ import * as fs from 'fs';
import * as path from 'path';
import {
assertSourceMapUpload,
- findDebugIdsInFilesWithGlobCharacters,
findDebugIdsWithConflictingSourcemaps,
findFilesWithForeignSourcemaps,
findFilesWithMultipleDebugIds,
@@ -73,9 +72,6 @@ const outputDirsWithSourceMaps = [
const debugIdsWithoutSourceMap = new Set(
outputDirsWithSourceMaps.flatMap(dir => [...debugIdsOfChunksWithoutSourceMap(dir)]),
);
-const debugIdsSkippedByUploader = isStaticBuild
- ? []
- : findDebugIdsInFilesWithGlobCharacters({ outputDir: SERVER_OUTPUT });
const { requests, bundles, sourcemaps, uploadedDebugIds } = assertSourceMapUpload({
outputDirs: outputDirsWithSourceMaps,
@@ -86,7 +82,7 @@ const { requests, bundles, sourcemaps, uploadedDebugIds } = assertSourceMapUploa
// 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],
+ debugIdsWithoutSourceMap: [...debugIdsWithoutSourceMap],
});
console.log(`Found ${bundles.length} artifact bundle(s)\n`);
diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/docker-compose.yml b/dev-packages/e2e-tests/test-applications/nuxt-5/docker-compose.yml
index 804b73fcee00..1aca60f85dd2 100644
--- a/dev-packages/e2e-tests/test-applications/nuxt-5/docker-compose.yml
+++ b/dev-packages/e2e-tests/test-applications/nuxt-5/docker-compose.yml
@@ -1,7 +1,7 @@
services:
mongo:
- # `mongo:8` floats to the newest 8.x release, whose server sometimes exits right after start on CI.
- image: mongo:8.0
+ # MongoDB 8 does not start on Linux kernel 6.19 to 7.0.13 (SERVER-121912), which GitHub-hosted runners use.
+ image: mongo:7.0
restart: always
container_name: e2e-tests-nuxt-5-mongo
ports:
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 8016834d1167..78c559af3621 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
@@ -28,6 +28,9 @@ export default defineNuxtConfig({
},
},
nitro: {
+ // Nitro v3 only routes `defineCachedFunction`/`defineCachedHandler` through KV storage (which the SDK
+ // instruments) with the `kv` cache driver. The default `memory` driver bypasses KV storage entirely.
+ cache: { driver: 'kv' },
experimental: {
database: true,
},
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 bb03b840799c..517736025e41 100644
--- a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json
+++ b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json
@@ -21,8 +21,7 @@
"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",
- "TODO: remove nitro from dependencies",
+ "Currently, we need to install the Nuxt nightlies as those contain Nuxt v5",
"Nuxt generates `fetch.server.mjs` as a virtual module that imports `ofetch`, and since a virtual id is neither absolute nor on disk, Vite resolves from its `root` (the srcDir) instead of the importer's folder.",
"This fails because `ofetch` is a dependency of `nuxt` rather than the app, so pnpm leaves it in `.pnpm/`",
"TODO: remove ofetch from dependencies: https://github.com/getsentry/sentry-javascript/pull/24283#issuecomment-5615854158"
@@ -30,9 +29,9 @@
"dependencies": {
"@pinia/nuxt": "^0.11.3",
"@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz",
+ "db0": "^0.4.2",
"mongoose": "^9.10.2",
"ofetch": "^2.0.0-alpha.3",
- "nitro": "latest",
"nuxt": "npm:nuxt-nightly@5x",
"vue": "^3.5.41"
},
@@ -47,7 +46,9 @@
"build-command": "pnpm test:build:tunnel-basepath",
"assert-command": "pnpm test:assert:tunnel-basepath",
"label": "nuxt-5 (tunnel-basepath)"
- },
+ }
+ ],
+ "optionalVariants": [
{
"build-command": "pnpm install",
"assert-command": "pnpm test:assert:tunnel-dev",
diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/01.first.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/01-first.ts
similarity index 100%
rename from dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/01.first.ts
rename to dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/01-first.ts
diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/02.second.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/02-second.ts
similarity index 100%
rename from dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/02.second.ts
rename to dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/02-second.ts
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
similarity index 100%
rename from dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts
rename to dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03-auth.ts
diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/04.hooks.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/04-hooks.ts
similarity index 100%
rename from dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/04.hooks.ts
rename to dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/04-hooks.ts
diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/05.array-hooks.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/05-array-hooks.ts
similarity index 100%
rename from dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/05.array-hooks.ts
rename to dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/05-array-hooks.ts
diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/nuxt-server/06.nuxt-server-first.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/nuxt-server/06-nuxt-server-first.ts
similarity index 100%
rename from dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/nuxt-server/06.nuxt-server-first.ts
rename to dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/nuxt-server/06-nuxt-server-first.ts
diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/nuxt-server/07.nuxt-server-auth.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/nuxt-server/07-nuxt-server-auth.ts
similarity index 100%
rename from dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/nuxt-server/07.nuxt-server-auth.ts
rename to dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/nuxt-server/07-nuxt-server-auth.ts
diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts
index e2c12def7864..8add88c82f06 100644
--- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts
+++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts
@@ -8,19 +8,19 @@ import { IMPORT_SURFACES } from './importSurfaces';
// simple middleware has a copy.
const MIDDLEWARE = {
nitro: {
- names: ['01.first', '02.second', '03.auth', '04.hooks', '05.array-hooks'],
- simpleNames: ['01.first', '02.second', '03.auth'],
+ names: ['01-first', '02-second', '03-auth', '04-hooks', '05-array-hooks'],
+ simpleNames: ['01-first', '02-second', '03-auth'],
// 3 simple + 2 hooks (middleware+handler) + 3 array hooks (2 middleware + 1 handler)
spanCount: 8,
- authName: '03.auth',
+ authName: '03-auth',
authErrorQuery: 'throwError=true',
authErrorMessage: 'Auth middleware error',
},
'nuxt/server': {
- names: ['06.nuxt-server-first', '07.nuxt-server-auth'],
- simpleNames: ['06.nuxt-server-first', '07.nuxt-server-auth'],
+ names: ['06-nuxt-server-first', '07-nuxt-server-auth'],
+ simpleNames: ['06-nuxt-server-first', '07-nuxt-server-auth'],
spanCount: 2,
- authName: '07.nuxt-server-auth',
+ authName: '07-nuxt-server-auth',
authErrorQuery: 'throwNuxtServerError=true',
authErrorMessage: 'nuxt/server auth middleware error',
},
@@ -127,17 +127,17 @@ test.describe('Server Middleware Instrumentation (nitro hooks)', () => {
expect(response.status()).toBe(200);
const spans = await spansPromise;
- const middlewareSpan = findMiddlewareSpan(spans, '04.hooks', 'middleware');
- const handlerSpan = findMiddlewareSpan(spans, '04.hooks', 'handler');
+ const middlewareSpan = findMiddlewareSpan(spans, '04-hooks', 'middleware');
+ const handlerSpan = findMiddlewareSpan(spans, '04-hooks', 'handler');
// h3 v2 has no `onBeforeResponse`, so a hooks object gives only `middleware` and `handler` spans.
- expect(spans.filter(span => span.attributes['nuxt.middleware.name']?.value === '04.hooks')).toHaveLength(2);
+ expect(spans.filter(span => span.attributes['nuxt.middleware.name']?.value === '04-hooks')).toHaveLength(2);
- expect(middlewareSpan?.name).toBe('04.hooks.middleware');
+ expect(middlewareSpan?.name).toBe('04-hooks.middleware');
// h3 v2 always stores `middleware` as an array, so even a single hook has an index.
expect(middlewareSpan?.attributes['nuxt.middleware.hook.index']?.value).toBe(0);
- expect(handlerSpan?.name).toBe('04.hooks');
+ expect(handlerSpan?.name).toBe('04-hooks');
expect(handlerSpan?.attributes['nuxt.middleware.hook.index']).toBeUndefined();
});
@@ -148,15 +148,15 @@ test.describe('Server Middleware Instrumentation (nitro hooks)', () => {
expect(response.status()).toBe(200);
const spans = await spansPromise;
- const arrayHooksSpans = spans.filter(span => span.attributes['nuxt.middleware.name']?.value === '05.array-hooks');
- const middleware0Span = findMiddlewareSpan(spans, '05.array-hooks', 'middleware', 0);
- const middleware1Span = findMiddlewareSpan(spans, '05.array-hooks', 'middleware', 1);
- const handlerSpan = findMiddlewareSpan(spans, '05.array-hooks', 'handler');
+ const arrayHooksSpans = spans.filter(span => span.attributes['nuxt.middleware.name']?.value === '05-array-hooks');
+ const middleware0Span = findMiddlewareSpan(spans, '05-array-hooks', 'middleware', 0);
+ const middleware1Span = findMiddlewareSpan(spans, '05-array-hooks', 'middleware', 1);
+ const handlerSpan = findMiddlewareSpan(spans, '05-array-hooks', 'handler');
expect(arrayHooksSpans).toHaveLength(3);
- expect(middleware0Span?.name).toBe('05.array-hooks.middleware');
- expect(middleware1Span?.name).toBe('05.array-hooks.middleware');
- expect(handlerSpan?.name).toBe('05.array-hooks');
+ expect(middleware0Span?.name).toBe('05-array-hooks.middleware');
+ expect(middleware1Span?.name).toBe('05-array-hooks.middleware');
+ expect(handlerSpan?.name).toBe('05-array-hooks');
expect(handlerSpan?.attributes['nuxt.middleware.hook.index']).toBeUndefined();
});
@@ -171,7 +171,7 @@ test.describe('Server Middleware Instrumentation (nitro hooks)', () => {
const [spans, errorEvent] = await Promise.all([spansPromise, errorEventPromise]);
- expect(findMiddlewareSpan(spans, '04.hooks', 'middleware')?.status).toBe('error');
+ expect(findMiddlewareSpan(spans, '04-hooks', 'middleware')?.status).toBe('error');
expect(errorEvent.exception?.values?.[0]?.value).toBe('OnRequest hook error');
});
@@ -186,8 +186,8 @@ test.describe('Server Middleware Instrumentation (nitro hooks)', () => {
const [spans, errorEvent] = await Promise.all([spansPromise, errorEventPromise]);
- expect(findMiddlewareSpan(spans, '05.array-hooks', 'middleware', 0)?.status).toBe('ok');
- expect(findMiddlewareSpan(spans, '05.array-hooks', 'middleware', 1)?.status).toBe('error');
+ expect(findMiddlewareSpan(spans, '05-array-hooks', 'middleware', 0)?.status).toBe('ok');
+ expect(findMiddlewareSpan(spans, '05-array-hooks', 'middleware', 1)?.status).toBe('error');
expect(errorEvent.exception?.values?.[0]?.value).toBe('OnRequest[1] hook error');
});
});
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 a6c572225b97..80481c626bd4 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,5 +45,13 @@
"@playwright/test": "1.64.1",
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz",
"serve": "14.0.1"
+ },
+ "sentryTest": {
+ "canaryVariants": [
+ {
+ "build-command": "pnpm test:build-canary",
+ "label": "react-create-hash-router (canary)"
+ }
+ ]
}
}
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 eabd1c9498b6..d61d433093d3 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
@@ -49,6 +49,11 @@
"npm-run-all2": "^6.2.0"
},
"sentryTest": {
- "runner": "namespace-profile-sentry-javascript"
+ "canaryVariants": [
+ {
+ "build-command": "pnpm test:build-canary",
+ "label": "react-router-6 (canary)"
+ }
+ ]
}
}
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 7d01de919ad9..c0525a3ddc64 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
@@ -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"
- },
- "sentryTest": {
- "runner": "namespace-profile-sentry-javascript"
}
}
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 56c20ce35f2d..151640418a31 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
@@ -69,7 +69,6 @@
]
},
"sentryTest": {
- "runner": "namespace-profile-sentry-javascript",
"variants": [
{
"build-command": "pnpm test:build-latest",
diff --git a/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/assert-build.ts
index 2bc4986220d4..ca6acfb5a6aa 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/assert-build.ts
+++ b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/assert-build.ts
@@ -111,4 +111,12 @@ for (const chunk of chunks) {
assert.ok(crossCheckedChunks > 0, 'Expected at least one uploaded chunk to cross-check debug IDs against');
console.log(`${crossCheckedChunks} chunk(s) ship a debug ID that was uploaded\n`);
+const remainingClientMaps = fs
+ .readdirSync(CLIENT_ASSETS_DIR, { recursive: true })
+ .filter(file => file.endsWith('.map'));
+assert.deepEqual(remainingClientMaps, [], 'Expected client source maps to be deleted after upload');
+
+const remainingServerMaps = fs.readdirSync('build/server', { recursive: true }).filter(file => file.endsWith('.map'));
+assert.ok(remainingServerMaps.length > 0, 'Expected server source maps outside the deletion pattern to be preserved');
+
console.log('All react-router source map 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 a8336229c229..f0c55b3ba846 100644
--- a/dev-packages/e2e-tests/test-applications/solidstart-2/package.json
+++ b/dev-packages/e2e-tests/test-applications/solidstart-2/package.json
@@ -9,7 +9,10 @@
"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": "node assert-build.ts && pnpm test:prod"
+ "test:assert": "node assert-build.ts && pnpm test:prod",
+ "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 pnpm test:prod 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": {
@@ -35,6 +38,18 @@
"node": ">=24"
},
"sentryTest": {
- "optional": true
+ "optional": true,
+ "variants": [
+ {
+ "build-command": "pnpm test:build:tunnel-basepath",
+ "assert-command": "pnpm test:assert:tunnel-basepath",
+ "label": "solidstart-2 (tunnel-basepath)"
+ },
+ {
+ "build-command": "pnpm install",
+ "assert-command": "pnpm test:assert:tunnel-basepath-dev",
+ "label": "solidstart-2 (tunnel-basepath-dev)"
+ }
+ ]
}
}
diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/solidstart-2/playwright.config.mjs
index b52ff06a5105..f1bae85626ba 100644
--- a/dev-packages/e2e-tests/test-applications/solidstart-2/playwright.config.mjs
+++ b/dev-packages/e2e-tests/test-applications/solidstart-2/playwright.config.mjs
@@ -1,7 +1,7 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';
const config = getPlaywrightConfig({
- startCommand: 'pnpm start',
+ startCommand: process.env.TEST_ENV === 'development' ? `HOST=localhost pnpm exec vite dev --port 3030` : 'pnpm start',
port: 3030,
});
diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/server/middleware/auth.ts b/dev-packages/e2e-tests/test-applications/solidstart-2/server/middleware/auth.ts
new file mode 100644
index 000000000000..ae6375805232
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/solidstart-2/server/middleware/auth.ts
@@ -0,0 +1,8 @@
+import { defineHandler, HTTPError } 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' });
+ }
+});
diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/server/plugins/sentry.ts b/dev-packages/e2e-tests/test-applications/solidstart-2/server/plugins/sentry.ts
index 812abe8255fb..9a8a30675c38 100644
--- a/dev-packages/e2e-tests/test-applications/solidstart-2/server/plugins/sentry.ts
+++ b/dev-packages/e2e-tests/test-applications/solidstart-2/server/plugins/sentry.ts
@@ -8,6 +8,7 @@ export default definePlugin(() => {
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-2/src/entry-client.tsx b/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-client.tsx
index 17e38cf08b72..e4413bd7e137 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
@@ -4,13 +4,15 @@ 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-2/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/solidstart-2/tests/tunnel.test.ts
new file mode 100644
index 000000000000..def91eb9d5e6
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/solidstart-2/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 variants');
+
+const APP_NAME = 'solidstart-2';
+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}/`, { 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/solidstart-2/vite.config.ts b/dev-packages/e2e-tests/test-applications/solidstart-2/vite.config.ts
index 74811201d2cf..24cb35bad781 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
@@ -25,6 +25,11 @@ function recordChunksWithoutSourceMap() {
}
export default defineConfig({
+ // The base-path variant serves the app, and so the tunnel, under `/app`.
+ ...(process.env.E2E_TEST_BASE_PATH && { base: `${process.env.E2E_TEST_BASE_PATH}/` }),
+ define: {
+ __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL),
+ },
plugins: [
solidStart({
appRoot: './src',
diff --git a/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/app.config.ts b/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/app.config.ts
index f41b1cb186ef..9835b34f5592 100644
--- a/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/app.config.ts
+++ b/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/app.config.ts
@@ -1,4 +1,4 @@
-import { withSentry } from '@sentry/solidstart';
+import { withSentry } from '@sentry/solidstart/vite';
import { defineConfig } from '@solidjs/start/config';
export default defineConfig(
diff --git a/dev-packages/e2e-tests/test-applications/solidstart-spa/app.config.ts b/dev-packages/e2e-tests/test-applications/solidstart-spa/app.config.ts
index 103ecb09a469..447d859e9d34 100644
--- a/dev-packages/e2e-tests/test-applications/solidstart-spa/app.config.ts
+++ b/dev-packages/e2e-tests/test-applications/solidstart-spa/app.config.ts
@@ -1,4 +1,4 @@
-import { withSentry } from '@sentry/solidstart';
+import { withSentry } from '@sentry/solidstart/vite';
import { defineConfig } from '@solidjs/start/config';
export default defineConfig(
diff --git a/dev-packages/e2e-tests/test-applications/solidstart-static/app.config.ts b/dev-packages/e2e-tests/test-applications/solidstart-static/app.config.ts
index 71061cf25d96..f3d634978c16 100644
--- a/dev-packages/e2e-tests/test-applications/solidstart-static/app.config.ts
+++ b/dev-packages/e2e-tests/test-applications/solidstart-static/app.config.ts
@@ -1,4 +1,4 @@
-import { withSentry } from '@sentry/solidstart';
+import { withSentry } from '@sentry/solidstart/vite';
import { defineConfig } from '@solidjs/start/config';
export default defineConfig(
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 38ddeac40f07..1952ea19d33a 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.29.0",
+ "@sentry/conventions": "0.30.0",
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz",
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^1.0.0",
diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/app.config.ts b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/app.config.ts
index e4e73e9fc570..f8211b6f0b52 100644
--- a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/app.config.ts
+++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/app.config.ts
@@ -1,4 +1,4 @@
-import { withSentry } from '@sentry/solidstart';
+import { withSentry } from '@sentry/solidstart/vite';
import { defineConfig } from '@solidjs/start/config';
export default defineConfig(
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 be3bce2116a7..fb663eb94953 100644
--- a/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts
+++ b/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts
@@ -1,4 +1,4 @@
-import { withSentry } from '@sentry/solidstart';
+import { withSentry } from '@sentry/solidstart/vite';
import { defineConfig } from '@solidjs/start/config';
export default defineConfig(
diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts
index 79c9861008f7..6b28f1ee0e9f 100644
--- a/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts
+++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts
@@ -1,5 +1,10 @@
import { expect, test } from '@playwright/test';
-import { collectStreamedSpansUntilSegment, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
+import {
+ collectStreamedSpansUntilSegment,
+ getSpanOp,
+ waitForEnvelopeItem,
+ waitForStreamedSpan,
+} from '@sentry-internal/test-utils';
type StreamedSpan = Awaited>;
@@ -65,7 +70,21 @@ test('Sends client-side Supabase db-operation spans to Sentry', async ({ page })
);
});
- const insertSpanPromise = waitForStreamedSpan('supabase-nextjs', span => span.name === 'insert todos');
+ let insertSpanSent = false;
+ waitForStreamedSpan('supabase-nextjs', span => {
+ if (span.name === 'insert todos') {
+ insertSpanSent = true;
+ }
+ return false;
+ }).catch(() => {});
+
+ const clientReportPromise = waitForEnvelopeItem('supabase-nextjs', ([header, payload]) => {
+ if (header.type !== 'client_report') {
+ return false;
+ }
+ const { discarded_events } = payload as { discarded_events: Array<{ reason: string; category: string }> };
+ return discarded_events.some(e => e.reason === 'no_parent_span' && e.category === 'span');
+ });
await page.goto('/');
@@ -78,15 +97,21 @@ test('Sends client-side Supabase db-operation spans to Sentry', async ({ page })
// Wait for login to complete
await page.waitForSelector('button:has-text("Add")');
+ const [pageloadSpan, selectSpan] = await Promise.all([pageloadSpanPromise, selectSpanPromise]);
+
// Add a new todo entry
- await page.locator('input[id=new-task-text]').fill('test');
+ const task = `test-${Date.now()}`;
+ await page.locator('input[id=new-task-text]').fill(task);
await page.locator('button[id=add-task]').click();
+ await page.getByText(task).waitFor();
+
+ // Hiding the page flushes the pending client report.
+ await page.evaluate(() => {
+ Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' });
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
- const [pageloadSpan, selectSpan, insertSpan] = await Promise.all([
- pageloadSpanPromise,
- selectSpanPromise,
- insertSpanPromise,
- ]);
+ await clientReportPromise;
// Client-side database query data is collected by default.
expectDbSpan(selectSpan, 'select todos', {
@@ -94,13 +119,10 @@ test('Sends client-side Supabase db-operation spans to Sentry', async ({ page })
'db.query': { value: ['select(*)', 'filter(order, asc)'], type: 'array' },
});
expect(selectSpan.trace_id).toBe(pageloadSpan.trace_id);
+ expect(selectSpan.parent_span_id).toBe(pageloadSpan.span_id);
- // The insert is triggered long after the pageload span has ended, so it is streamed on its own
- // rather than as a child of the pageload span.
- expectDbSpan(insertSpan, 'insert todos', {
- 'db.operation.name': { value: 'insert', type: 'string' },
- 'db.query': { value: ['select(*)'], type: 'array' },
- });
+ // The insert runs after the pageload span has ended, so it has no parent and no span is sent.
+ expect(insertSpanSent).toBe(false);
});
test('Sends server-side Supabase db-operation spans to Sentry', async ({ baseURL }) => {
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 32ccc8e5fffd..6d6dd79531a7 100644
--- a/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json
+++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json
@@ -10,8 +10,9 @@
"clean": "npx rimraf node_modules pnpm-lock.yaml",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
+ "check:sentry-types": "svelte-kit sync && tsc -p tsconfig.sentry-types.json",
"test:prod": "TEST_ENV=production playwright test",
- "test:build": "pnpm install && pnpm build",
+ "test:build": "pnpm install && pnpm check:sentry-types && pnpm build",
"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",
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/sentryTypes.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/sentryTypes.ts
new file mode 100644
index 000000000000..c57f9fefd833
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/sentryTypes.ts
@@ -0,0 +1,12 @@
+import type * as Sentry from '@sentry/sveltekit';
+
+// If the SDK's declarations fail to resolve a SvelteKit type, `skipLibCheck` hides the error and
+// the type accepts anything. That unresolved type isn't caught by the usual `0 extends 1 & T` any
+// check, so test for a top type instead. Checked by `check:sentry-types` in `test:build`.
+type AcceptsAnything = unknown extends T ? true : false;
+type ExpectFalse = T;
+
+export type SentryTypeChecks = [
+ ExpectFalse>>,
+ ExpectFalse>>,
+];
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/tsconfig.sentry-types.json b/dev-packages/e2e-tests/test-applications/sveltekit-3/tsconfig.sentry-types.json
new file mode 100644
index 000000000000..8dfbb473d959
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/tsconfig.sentry-types.json
@@ -0,0 +1,7 @@
+{
+ "extends": "./tsconfig.json",
+ "include": ["src/sentryTypes.ts"],
+ "compilerOptions": {
+ "noEmit": true
+ }
+}
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
index 2aa44e66f20e..bb92e6eb459b 100644
--- 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
@@ -59,16 +59,6 @@ function sourceMapsIn(dir: string): string[] {
.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 ---
@@ -125,10 +115,7 @@ 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 exemptDebugIds = debugIdsOfChunksWithoutSourceMap(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`);
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 eb862db2b1e1..69c718ec1dee 100644
--- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json
+++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json
@@ -79,6 +79,12 @@
"build-command": "pnpm install",
"assert-command": "pnpm test:assert:tunnel-basepath-dev"
}
+ ],
+ "optionalVariants": [
+ {
+ "build-command": "pnpm test:build-latest",
+ "label": "tanstackstart-react (latest)"
+ }
]
}
}
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 60707aed87b5..6048d7d8d9ff 100644
--- a/dev-packages/e2e-tests/test-applications/vue-3/package.json
+++ b/dev-packages/e2e-tests/test-applications/vue-3/package.json
@@ -56,15 +56,17 @@
}
],
"optionalVariants": [
- {
- "build-command": "pnpm test:build-canary",
- "label": "vue-3 (canary)"
- },
{
"build-command": "pnpm test:build-no-options-api",
"assert-command": "pnpm test:assert-no-options-api",
"label": "vue-3 (no Options API)"
}
+ ],
+ "canaryVariants": [
+ {
+ "build-command": "pnpm test:build-canary",
+ "label": "vue-3 (canary)"
+ }
]
}
}
diff --git a/dev-packages/external-contributor-gh-action/package.json b/dev-packages/external-contributor-gh-action/package.json
index 3a1b935b59ff..b6ea59defd34 100644
--- a/dev-packages/external-contributor-gh-action/package.json
+++ b/dev-packages/external-contributor-gh-action/package.json
@@ -13,10 +13,7 @@
"lint": "oxlint . --type-aware",
"lint:fix": "oxlint . --fix --type-aware"
},
- "dependencies": {
- "@actions/core": "1.10.1"
- },
"devDependencies": {
- "eslint-plugin-regexp": "^3.1.0"
+ "@actions/core": "3.0.1"
}
}
diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json
index 8864887bdc97..c6413087ad1a 100644
--- a/dev-packages/node-integration-tests/package.json
+++ b/dev-packages/node-integration-tests/package.json
@@ -24,60 +24,71 @@
"test": "vitest run",
"test:watch": "pnpm run test --watch"
},
- "dependencies": {
- "@anthropic-ai/sdk": "0.133.0",
+ "devDependencies": {
+ "@anthropic-ai/sdk": "1.3.0",
"@apollo/server": "^5.5.0",
- "@aws-sdk/client-dynamodb": "^3.1046.0",
- "@aws-sdk/client-kinesis": "^3.1146.0",
- "@aws-sdk/client-lambda": "^3.1046.0",
- "@aws-sdk/client-s3": "^3.1046.0",
- "@aws-sdk/client-secrets-manager": "^3.1046.0",
- "@aws-sdk/client-sfn": "^3.1046.0",
- "@aws-sdk/client-sns": "^3.1046.0",
- "@aws-sdk/client-sqs": "^3.1046.0",
+ "@aws-sdk/client-bedrock-runtime": "^3.1161.0",
+ "@aws-sdk/client-dynamodb": "^3.1161.0",
+ "@aws-sdk/client-kinesis": "^3.1161.0",
+ "@aws-sdk/client-lambda": "^3.1161.0",
+ "@aws-sdk/client-s3": "^3.1161.0",
+ "@aws-sdk/client-secrets-manager": "^3.1161.0",
+ "@aws-sdk/client-sfn": "^3.1161.0",
+ "@aws-sdk/client-sns": "^3.1161.0",
+ "@aws-sdk/client-sqs": "^3.1161.0",
+ "@earendil-works/chord": "^1.4.0",
+ "@earendil-works/pi-ai": "^1.4.0",
+ "@earendil-works/pi-durable": "^1.4.0",
+ "@flue/runtime": "2.3.0",
"@google/genai": "^1.20.0",
"@growthbook/growthbook": "^1.6.5",
+ "@hapi/boom": "^10.0.1",
"@hapi/hapi": "^21.3.10",
- "@hono/node-server": "^2.0.10",
+ "@hono/node-server": "^2.1.5",
"@koa/router": "^12.0.1",
"@langchain/anthropic": "^0.3.10",
"@langchain/core": "^0.3.80",
"@langchain/langgraph": "^0.2.32",
"@langchain/openai": "^0.5.0",
- "@mistralai/mistralai": "2.6.4",
+ "@mistralai/mistralai": "3.3.0",
"@modelcontextprotocol/client": "^2.0.0",
+ "@modelcontextprotocol/sdk": "1.32.2",
"@modelcontextprotocol/server": "^2.0.0",
- "@nestjs/common": "^11",
- "@nestjs/core": "^11",
- "@nestjs/platform-express": "^11",
- "@prisma/adapter-pg": "7.9.1",
+ "@neondatabase/serverless": "1.2.0",
+ "@prisma/adapter-pg": "7.2.0",
"@prisma/client": "6.15.0",
+ "@prisma/orm-postgres": "8.0.0-rc.17",
+ "@sentry-internal/rollup-utils": "11.10.0",
+ "@sentry-internal/test-utils": "11.10.0",
"@sentry/aws-serverless": "11.10.0",
+ "@sentry/conventions": "0.30.0",
"@sentry/core": "11.10.0",
"@sentry/hono": "11.10.0",
"@sentry/node": "11.10.0",
+ "@sentry/node-native": "11.10.0",
"@sentry/server-runtime-injection": "11.10.0",
"@sentry/server-utils": "11.10.0",
"@smithy/node-http-handler": "4.12.1",
- "@types/mongodb": "^3.6.20",
- "@types/mysql": "^2.15.21",
- "@types/pg": "^8.6.5",
+ "@types/express": "^4.17.21",
+ "@types/node-schedule": "^2.1.8",
+ "@typesafe-ai/sdk": "^0.6.0",
"ai": "^4.3.16",
"amqplib": "^0.10.9",
"body-parser": "^2.3.0",
+ "bullmq": "^5.79.1",
"consola": "^3.2.3",
"cors": "^2.8.5",
- "cron": "^3.1.6",
- "dataloader": "2.2.2",
+ "cron": "^4.4.0",
+ "dataloader": "2.2.3",
+ "esbuild": "0.28.1",
"express": "^4.21.2",
"fastify": "^5.12.7",
"generic-pool": "^3.9.0",
"graphql": "^16.11.0",
+ "graphql-tag": "^2.12.7",
"groq-sdk": "1.6.0",
"h3": "2.0.1-rc.22",
- "graphql-tag": "^2.12.7",
"hono": "^4.13.7",
- "http-terminator": "^3.2.0",
"ioredis": "5.10.1",
"ioredis-5": "npm:ioredis@^5.11.0",
"ioredis-6": "npm:ioredis@^6.0.0",
@@ -92,44 +103,27 @@
"mysql2": "^3.23.3",
"nock": "^14.0.17",
"node-cron": "^4.6.0",
- "node-schedule": "^2.1.1",
+ "node-schedule": "^2.1.2",
"openai": "5.18.1",
- "pg": "8.20.0",
+ "pg": "8.23.1",
"pino": "10.4.0",
"pino-next": "npm:pino@^9.12.0",
"postgres": "^3.4.7",
"prisma": "6.15.0",
- "proxy": "^2.1.1",
+ "proxy": "^4.1.0",
+ "react": "^18.3.1",
"redis-4": "npm:redis@^4.6.14",
"redis-5": "npm:redis@~5.11.0",
"redis-5-tracing": "npm:redis@^5.12.0",
- "reflect-metadata": "0.2.2",
- "together-ai": "0.54.0",
- "rxjs": "^7.8.2",
"srvx": "^0.11.22",
"tedious": "^19.2.1",
+ "together-ai": "0.54.0",
"unstorage": "2.0.0-alpha.7",
+ "valibot": "1.5.0",
"winston": "^3.17.0",
- "ws": "^8.20.1",
- "yargs": "^16.2.0"
- },
- "devDependencies": {
- "@hapi/boom": "^10.0.1",
- "@neondatabase/serverless": "1.2.0",
- "@opentelemetry/instrumentation-http": "0.220.0",
- "@sentry-internal/rollup-utils": "11.10.0",
- "@sentry/conventions": "0.29.0",
- "@sentry-internal/test-utils": "11.10.0",
- "@sentry/node-native": "11.10.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",
"winston-transport": "^4.9.0",
+ "ws": "^8.20.1",
+ "yargs": "^18.2.0",
"zod": "^4.6.7"
},
"config": {
diff --git a/dev-packages/node-integration-tests/suites/aws-lambda-web/requests/instrument.mjs b/dev-packages/node-integration-tests/suites/aws-lambda-web/requests/instrument.mjs
new file mode 100644
index 000000000000..d5824acab7ba
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/aws-lambda-web/requests/instrument.mjs
@@ -0,0 +1,8 @@
+import * as Sentry from '@sentry/node';
+import { loggingTransport } from '@sentry-internal/node-integration-tests';
+
+Sentry.init({
+ dsn: 'https://public@example.com/1',
+ tracesSampleRate: process.env.NO_TRACING ? undefined : 1,
+ transport: loggingTransport,
+});
diff --git a/dev-packages/node-integration-tests/suites/aws-lambda-web/requests/scenario.mjs b/dev-packages/node-integration-tests/suites/aws-lambda-web/requests/scenario.mjs
new file mode 100644
index 000000000000..c3fea461fdd1
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/aws-lambda-web/requests/scenario.mjs
@@ -0,0 +1,28 @@
+import * as Sentry from '@sentry/node';
+import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests';
+import express from 'express';
+
+const app = express();
+let releaseFirst;
+const secondRequestStarted = new Promise(resolve => {
+ releaseFirst = resolve;
+});
+
+app.get('/:request', async (req, res) => {
+ if (req.params.request === 'first') {
+ process.stdout.write('FIRST_STARTED\n');
+ await secondRequestStarted;
+ } else {
+ releaseFirst();
+ }
+
+ Sentry.captureException(new Error(req.params.request));
+ Sentry.logger.info('Request handled');
+ Sentry.metrics.count('request_count', 1);
+ res.send({ ok: true });
+ setImmediate(() => {
+ void Sentry.flush();
+ });
+});
+
+startExpressServerAndSendPortToRunner(app);
diff --git a/dev-packages/node-integration-tests/suites/aws-lambda-web/requests/test.ts b/dev-packages/node-integration-tests/suites/aws-lambda-web/requests/test.ts
new file mode 100644
index 000000000000..270e9777f8d7
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/aws-lambda-web/requests/test.ts
@@ -0,0 +1,111 @@
+import {
+ AWS_LAMBDA_WEB_ENDPOINT_NAME,
+ CLOUD_PLATFORM,
+ CLOUD_PROVIDER,
+ CLOUD_REGION,
+ FAAS_COLDSTART,
+ FAAS_INVOCATION_ID,
+ FAAS_MAX_MEMORY,
+ SENTRY_OP,
+} from '@sentry/conventions/attributes';
+import { expect, vi } from 'vitest';
+import { createEsmAndCjsTests } from '../../../utils/runner';
+
+createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
+ test.each([
+ { name: 'default tracing', tracing: true },
+ { name: 'tracing disabled', tracing: false },
+ ])('$name: isolates concurrent requests and leaves logs and metrics unenriched', async ({ tracing }) => {
+ const runner = createRunner()
+ .withEnv({
+ AWS_LAMBDA_HTTP_ENDPOINT: '0.0.0.0:3000',
+ AWS_REGION: 'us-east-1',
+ AWS_LAMBDA_FUNCTION_MEMORY_SIZE: '2048',
+ AWS_LAMBDA_INITIALIZATION_TYPE: 'native-http',
+ AWS_EXECUTION_ENV: 'AWS_Lambda_nodejs24.x-lite',
+ NO_TRACING: tracing ? '' : '1',
+ })
+ .unordered();
+
+ const spans = [];
+
+ for (const request of ['first', 'second']) {
+ runner.expect({
+ event: {
+ exception: { values: [expect.objectContaining({ value: request })] },
+ contexts: {
+ cloud_resource: {
+ [CLOUD_PROVIDER]: 'aws',
+ [CLOUD_PLATFORM]: 'aws_lambda',
+ [CLOUD_REGION]: 'us-east-1',
+ },
+ 'aws.lambda': {
+ memory_size: 2_147_483_648,
+ initialization_type: 'native-http',
+ cold_start: request === 'first',
+ aws_request_id: request,
+ endpoint_name: `${request}-endpoint`,
+ },
+ },
+ },
+ });
+
+ if (tracing) {
+ spans.push({
+ request,
+ collected: runner.collectStreamedSpansUntilSegment(
+ span => span.attributes[FAAS_INVOCATION_ID]?.value === request,
+ ),
+ });
+ }
+ }
+
+ const assertUnenriched = (items: { attributes?: Record }[]): void => {
+ expect(items).toHaveLength(2);
+ for (const item of items) {
+ expect(Object.keys(item.attributes || {}).filter(key => /^(aws\.lambda\.|faas\.|cloud\.)/.test(key))).toEqual(
+ [],
+ );
+ }
+ };
+
+ runner.expect({ log: container => assertUnenriched(container.items) });
+ runner.expect({ trace_metric: container => assertUnenriched(container.items) });
+
+ const running = runner.start();
+ const first = running.makeRequest('get', '/first', {
+ headers: { 'lambda-web-request-id': 'first', 'lambda-web-endpoint-name': 'first-endpoint' },
+ });
+ await vi.waitFor(() => expect(running.getLogs().join('\n')).toContain('FIRST_STARTED'), { timeout: 10_000 });
+
+ await Promise.all([
+ first,
+ running.makeRequest('get', '/second', {
+ headers: { 'lambda-web-request-id': 'second', 'lambda-web-endpoint-name': 'second-endpoint' },
+ }),
+ ]);
+ await running.completed();
+
+ const segmentSpans = [];
+
+ for (const { request, collected } of spans) {
+ const items = await collected;
+ segmentSpans.push(...items.filter(item => item.is_segment));
+
+ expect(items.find(item => item.is_segment)).toMatchObject({
+ name: 'GET /:request',
+ attributes: {
+ [SENTRY_OP]: { type: 'string', value: 'http.server' },
+ [CLOUD_PLATFORM]: { type: 'string', value: 'aws_lambda' },
+ [CLOUD_REGION]: { type: 'string', value: 'us-east-1' },
+ [FAAS_MAX_MEMORY]: { type: 'integer', value: 2_147_483_648 },
+ [FAAS_COLDSTART]: { type: 'boolean', value: request === 'first' },
+ [FAAS_INVOCATION_ID]: { type: 'string', value: request },
+ [AWS_LAMBDA_WEB_ENDPOINT_NAME]: { type: 'string', value: `${request}-endpoint` },
+ },
+ });
+ }
+
+ expect(segmentSpans).toHaveLength(tracing ? 2 : 0);
+ });
+});
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 0a86d5232552..018326729159 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
@@ -57,15 +57,9 @@ describe('awsIntegration - Bedrock', () => {
cleanupChildProcesses();
});
- createEsmAndCjsTests(
- __dirname,
- 'scenario.mjs',
- 'instrument.mjs',
- (createTestRunner, test) => {
- test('auto-instruments Bedrock Converse and InvokeModel', { timeout: 90_000 }, async () => {
- await createTestRunner().ignore('event').expect({ span: assertBedrockSpans }).start().completed();
- });
- },
- { additionalDependencies: { '@aws-sdk/client-bedrock-runtime': '^3.1046.0' } },
- );
+ createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createTestRunner, test) => {
+ test('auto-instruments Bedrock Converse and InvokeModel', { timeout: 90_000 }, async () => {
+ await createTestRunner().ignore('event').expect({ span: assertBedrockSpans }).start().completed();
+ });
+ });
});
diff --git a/dev-packages/node-integration-tests/suites/cron/cron-integration/instrument.mjs b/dev-packages/node-integration-tests/suites/cron/cron-integration/instrument.mjs
new file mode 100644
index 000000000000..3ecfc0097afc
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/cron/cron-integration/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@dsn.ingest.sentry.io/1337',
+ release: '1.0',
+ transport: loggingTransport,
+ integrations: [Sentry.cronJobIntegration({ excludeJobs: ['excluded-job'] })],
+});
diff --git a/dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs b/dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs
new file mode 100644
index 000000000000..0a665ee9727c
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs
@@ -0,0 +1,37 @@
+import { CronJob, CronTime } from 'cron';
+
+const excluded = CronJob.from({ cronTime: '0 0 * * *', onTick: () => {}, name: 'excluded-job' });
+const unnamed = new CronJob('0 0 * * *', () => {});
+const oneOff = CronJob.from({ cronTime: new Date(Date.now() + 86_400_000), onTick: () => {}, name: 'one-off-job' });
+const offset = CronJob.from({ cronTime: '0 0 * * *', utcOffset: 60, onTick: () => {}, name: 'offset-job' });
+const subMinute = CronJob.from({ cronTime: '*/10 * * * * *', onTick: () => {}, name: 'sub-minute-job' });
+
+let runs = 0;
+const job = CronJob.from({
+ cronTime: '0 30 9 * * 1-5',
+ timeZone: 'Europe/Vienna',
+ name: 'my-cron-job',
+ waitForCompletion: true,
+ errorHandler: () => {},
+ onTick: async () => {
+ runs++;
+ if (runs === 2) {
+ throw new Error('Error in cron job');
+ }
+ },
+});
+
+// Run the jobs without waiting for their schedule.
+(async () => {
+ await excluded.fireOnTick();
+ await unnamed.fireOnTick();
+ await oneOff.fireOnTick();
+ await offset.fireOnTick();
+ await subMinute.fireOnTick();
+ await job.fireOnTick();
+ await job.fireOnTick();
+ job.setTime(new CronTime('0 8 * * *', 'UTC'));
+ await job.fireOnTick();
+})();
+
+setTimeout(() => process.exit(), 10_000);
diff --git a/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts b/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts
new file mode 100644
index 000000000000..87a0286c5309
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts
@@ -0,0 +1,58 @@
+import { afterAll, describe, expect } from 'vitest';
+import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
+
+const MONITOR_CONFIG = {
+ schedule: { type: 'crontab', value: '30 9 * * 1-5' },
+ timezone: 'Europe/Vienna',
+};
+
+describe('cronJobIntegration', () => {
+ afterAll(() => {
+ cleanupChildProcesses();
+ });
+
+ createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
+ test('sends check-ins for named jobs', async () => {
+ await createRunner()
+ .ignore('event')
+ .expect({
+ check_in: { monitor_slug: 'my-cron-job', status: 'in_progress', monitor_config: MONITOR_CONFIG },
+ })
+ .expect({ check_in: { monitor_slug: 'my-cron-job', status: 'ok', duration: expect.any(Number) } })
+ .expect({
+ check_in: { monitor_slug: 'my-cron-job', status: 'in_progress', monitor_config: MONITOR_CONFIG },
+ })
+ .expect({ check_in: { monitor_slug: 'my-cron-job', status: 'error', duration: expect.any(Number) } })
+ .expect({
+ check_in: {
+ monitor_slug: 'my-cron-job',
+ status: 'in_progress',
+ monitor_config: { schedule: { type: 'crontab', value: '0 8 * * *' }, timezone: 'UTC' },
+ },
+ })
+ .expect({ check_in: { monitor_slug: 'my-cron-job', status: 'ok', duration: expect.any(Number) } })
+ .start()
+ .completed();
+ });
+
+ test('captures job errors', async () => {
+ await createRunner()
+ .unordered()
+ .expect({
+ event: {
+ exception: {
+ values: [
+ {
+ type: 'Error',
+ value: 'Error in cron job',
+ mechanism: { type: 'auto.function.cron', handled: false },
+ },
+ ],
+ },
+ },
+ })
+ .start()
+ .completed();
+ });
+ });
+});
diff --git a/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts b/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts
index b3966b050d89..68cf28bcde94 100644
--- a/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts
+++ b/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts
@@ -28,11 +28,11 @@ cron.start();
// is enough to hit it, and the job is left scheduled for nothing: no ticks, no check-ins, and the
// scenario exits on the timeout below having sent nothing at all. Starting again re-reads the clock
// away from the boundary.
-for (let attempt = 0; attempt < 5 && !cron.running; attempt++) {
+for (let attempt = 0; attempt < 5 && !cron.isActive; attempt++) {
cron.start();
}
-if (!cron.running) {
+if (!cron.isActive) {
throw new Error('`cron` refused to schedule the job');
}
diff --git a/dev-packages/node-integration-tests/suites/cron/node-cron/integration/instrument.mjs b/dev-packages/node-integration-tests/suites/cron/node-cron/integration/instrument.mjs
new file mode 100644
index 000000000000..521d7bbe12ff
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/cron/node-cron/integration/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@dsn.ingest.sentry.io/1337',
+ release: '1.0',
+ transport: loggingTransport,
+ integrations: [Sentry.nodeCronIntegration({ excludeJobs: ['excluded-job'] })],
+});
diff --git a/dev-packages/node-integration-tests/suites/cron/node-cron/integration/scenario.mjs b/dev-packages/node-integration-tests/suites/cron/node-cron/integration/scenario.mjs
new file mode 100644
index 000000000000..bee2fec76356
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/cron/node-cron/integration/scenario.mjs
@@ -0,0 +1,25 @@
+import cron from 'node-cron';
+
+const excluded = cron.schedule('0 0 0 1 1 *', () => {}, { name: 'excluded-job' });
+const unnamed = cron.schedule('0 0 0 1 1 *', () => {});
+
+let runs = 0;
+const task = cron.schedule(
+ '0 0 0 1 1 *',
+ () => {
+ runs++;
+ if (runs > 1) {
+ throw new Error('Error in cron job');
+ }
+ },
+ { name: 'my-cron-job', timezone: 'America/New_York' },
+);
+
+// Run the tasks by hand. Their schedules can't fire during the test.
+excluded
+ .execute()
+ .then(() => unnamed.execute())
+ .then(() => task.execute())
+ .then(() => task.execute());
+
+setTimeout(() => process.exit(), 10_000);
diff --git a/dev-packages/node-integration-tests/suites/cron/node-cron/integration/test.ts b/dev-packages/node-integration-tests/suites/cron/node-cron/integration/test.ts
new file mode 100644
index 000000000000..e90549ce84c8
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/cron/node-cron/integration/test.ts
@@ -0,0 +1,50 @@
+import { afterAll, describe, expect } from 'vitest';
+import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner';
+
+const MONITOR_CONFIG = {
+ schedule: { type: 'crontab', value: '0 0 1 1 *' },
+ timezone: 'America/New_York',
+};
+
+describe('nodeCronIntegration', () => {
+ afterAll(() => {
+ cleanupChildProcesses();
+ });
+
+ createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
+ test('sends check-ins for named tasks', async () => {
+ await createRunner()
+ .ignore('event')
+ .expect({
+ check_in: { monitor_slug: 'my-cron-job', status: 'in_progress', monitor_config: MONITOR_CONFIG },
+ })
+ .expect({ check_in: { monitor_slug: 'my-cron-job', status: 'ok', duration: expect.any(Number) } })
+ .expect({
+ check_in: { monitor_slug: 'my-cron-job', status: 'in_progress', monitor_config: MONITOR_CONFIG },
+ })
+ .expect({ check_in: { monitor_slug: 'my-cron-job', status: 'error', duration: expect.any(Number) } })
+ .start()
+ .completed();
+ });
+
+ test('captures job errors', async () => {
+ await createRunner()
+ .unordered()
+ .expect({
+ event: {
+ exception: {
+ values: [
+ {
+ type: 'Error',
+ value: 'Error in cron job',
+ mechanism: { type: 'auto.function.node-cron', handled: false },
+ },
+ ],
+ },
+ },
+ })
+ .start()
+ .completed();
+ });
+ });
+});
diff --git a/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/instrument.mjs b/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/instrument.mjs
new file mode 100644
index 000000000000..a3011f204b5e
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/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@dsn.ingest.sentry.io/1337',
+ release: '1.0',
+ transport: loggingTransport,
+ integrations: [Sentry.nodeScheduleIntegration({ excludeJobs: ['excluded-job'] })],
+});
diff --git a/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/scenario.mjs b/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/scenario.mjs
new file mode 100644
index 000000000000..7cb179a7483a
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/scenario.mjs
@@ -0,0 +1,35 @@
+import schedule from 'node-schedule';
+
+const excluded = schedule.scheduleJob('excluded-job', '0 0 * * *', () => {});
+const unnamed = schedule.scheduleJob('0 0 * * *', () => {});
+const oneOff = schedule.scheduleJob('one-off-job', Date.now() + 86_400_000, () => {});
+const recurrenceRule = schedule.scheduleJob('rule-job', { hour: 3, minute: 0 }, () => {});
+const ending = schedule.scheduleJob('ending-job', { rule: '0 0 * * *', end: Date.now() + 86_400_000 }, () => {});
+const newJob = new schedule.Job('new-job', () => {});
+newJob.schedule({ rule: '0 0 * * *', tz: 'UTC' });
+
+let runs = 0;
+const job = schedule.scheduleJob('my-schedule-job', { rule: '30 9 * * 1-5', tz: 'Europe/Vienna' }, () => {
+ runs++;
+ if (runs === 2) {
+ throw new Error('Error in scheduled job');
+ }
+});
+
+// Run the jobs without waiting for their schedule.
+excluded.invoke();
+unnamed.invoke();
+oneOff.invoke();
+recurrenceRule.invoke();
+ending.invoke();
+newJob.invoke();
+job.invoke();
+try {
+ job.invoke();
+} catch {
+ // node-schedule catches errors when it runs the job itself
+}
+schedule.rescheduleJob(job, { rule: '0 8 * * *', tz: 'UTC' });
+job.invoke();
+
+setTimeout(() => process.exit(), 10_000);
diff --git a/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/test.ts b/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/test.ts
new file mode 100644
index 000000000000..3a136ea82d02
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/test.ts
@@ -0,0 +1,66 @@
+import { afterAll, describe, expect } from 'vitest';
+import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
+
+const MONITOR_CONFIG = {
+ schedule: { type: 'crontab', value: '30 9 * * 1-5' },
+ timezone: 'Europe/Vienna',
+};
+
+describe('nodeScheduleIntegration', () => {
+ afterAll(() => {
+ cleanupChildProcesses();
+ });
+
+ createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
+ test('sends check-ins for named jobs', async () => {
+ await createRunner()
+ .ignore('event')
+ .expect({
+ check_in: {
+ monitor_slug: 'new-job',
+ status: 'in_progress',
+ monitor_config: { schedule: { type: 'crontab', value: '0 0 * * *' }, timezone: 'UTC' },
+ },
+ })
+ .expect({ check_in: { monitor_slug: 'new-job', status: 'ok' } })
+ .expect({
+ check_in: { monitor_slug: 'my-schedule-job', status: 'in_progress', monitor_config: MONITOR_CONFIG },
+ })
+ .expect({ check_in: { monitor_slug: 'my-schedule-job', status: 'ok', duration: expect.any(Number) } })
+ .expect({
+ check_in: { monitor_slug: 'my-schedule-job', status: 'in_progress', monitor_config: MONITOR_CONFIG },
+ })
+ .expect({ check_in: { monitor_slug: 'my-schedule-job', status: 'error', duration: expect.any(Number) } })
+ .expect({
+ check_in: {
+ monitor_slug: 'my-schedule-job',
+ status: 'in_progress',
+ monitor_config: { schedule: { type: 'crontab', value: '0 8 * * *' }, timezone: 'UTC' },
+ },
+ })
+ .expect({ check_in: { monitor_slug: 'my-schedule-job', status: 'ok', duration: expect.any(Number) } })
+ .start()
+ .completed();
+ });
+
+ test('captures job errors', async () => {
+ await createRunner()
+ .unordered()
+ .expect({
+ event: {
+ exception: {
+ values: [
+ {
+ type: 'Error',
+ value: 'Error in scheduled job',
+ mechanism: { type: 'auto.function.node-schedule', handled: false },
+ },
+ ],
+ },
+ },
+ })
+ .start()
+ .completed();
+ });
+ });
+});
diff --git a/dev-packages/node-integration-tests/suites/modules/test.ts b/dev-packages/node-integration-tests/suites/modules/test.ts
index 57c221f9f7f0..5e6b65cc8672 100644
--- a/dev-packages/node-integration-tests/suites/modules/test.ts
+++ b/dev-packages/node-integration-tests/suites/modules/test.ts
@@ -19,7 +19,7 @@ describe('modulesIntegration', () => {
express: expect.stringMatching(/^4\.\d+\.\d+$/),
// this comes from package.json
'@sentry/node': SDK_VERSION,
- yargs: '^16.2.0',
+ yargs: '^18.2.0',
},
},
})
@@ -78,7 +78,7 @@ describe('modulesIntegration', () => {
// this comes from package.json
express: expect.stringMatching(/^\^4\.\d+\.\d+$/),
'@sentry/node': SDK_VERSION,
- yargs: '^16.2.0',
+ yargs: '^18.2.0',
},
},
})
diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-with-response.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-with-response.mjs
index 704453d9b158..d07da8fb6349 100644
--- a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-with-response.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-with-response.mjs
@@ -6,16 +6,6 @@ function startMockAnthropicServer() {
const app = express();
app.use(express.json());
- app.post('/anthropic/v1/complete', (req, res) => {
- res.send({
- id: 'compl_withresponse',
- type: 'completion',
- model: req.body.model,
- completion: 'Testing .asResponse() method!',
- stop_reason: 'stop_sequence',
- });
- });
-
app.post('/anthropic/v1/messages', (req, res) => {
const model = req.body.model;
@@ -210,15 +200,6 @@ async function run() {
.asResponse();
await betaResponse.json();
- const completionResponse = await client.completions
- .create({
- model: 'claude-2',
- max_tokens_to_sample: 100,
- prompt: '\n\nHuman: Test asResponse\n\nAssistant:',
- })
- .asResponse();
- await completionResponse.json();
-
const rawStreamResponse = await client.messages
.create({
model: 'claude-3-haiku-20240307',
diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts
index ad9ee334d927..144d1e16a827 100644
--- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts
@@ -40,7 +40,7 @@ describe('Anthropic integration', () => {
span: container => {
expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.anthropic');
- expect(spans).toHaveLength(6);
+ expect(spans).toHaveLength(5);
const nonStreamingSpans = spans.filter(
span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'msg_withresponse',
);
@@ -60,7 +60,7 @@ describe('Anthropic integration', () => {
expect(streamingSpan!.attributes[GEN_AI_RESPONSE_STREAMING].value).toBe(true);
const rawResponseSpans = spans.filter(span => span.attributes[GEN_AI_RESPONSE_ID] === undefined);
- expect(rawResponseSpans).toHaveLength(4);
+ expect(rawResponseSpans).toHaveLength(3);
for (const span of rawResponseSpans) {
expect(span.name).toBe(`chat ${span.attributes[GEN_AI_REQUEST_MODEL].value}`);
expect(span.status).toBe('ok');
@@ -70,7 +70,6 @@ describe('Anthropic integration', () => {
expect(rawResponseSpans.map(span => span.attributes[GEN_AI_REQUEST_MODEL].value)).toEqual([
'claude-3-haiku-20240307',
'claude-3-haiku-20240307',
- 'claude-2',
'claude-3-haiku-20240307',
]);
},
diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/v0/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0/instrument.mjs
new file mode 100644
index 000000000000..cc192fb89834
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0/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/anthropic/v0/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0/scenario.mjs
new file mode 100644
index 000000000000..fb08dc7fcf76
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0/scenario.mjs
@@ -0,0 +1,118 @@
+import Anthropic from '@anthropic-ai/sdk';
+import * as Sentry from '@sentry/node';
+import express from 'express';
+
+function streamEvents(id, model) {
+ return [
+ {
+ type: 'message_start',
+ message: { id, type: 'message', role: 'assistant', model, content: [], usage: { input_tokens: 10 } },
+ },
+ { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
+ { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hello!' } },
+ { type: 'content_block_stop', index: 0 },
+ { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 15 } },
+ { type: 'message_stop' },
+ ];
+}
+
+function startMockAnthropicServer() {
+ const app = express();
+ app.use(express.json());
+
+ app.post('/anthropic/v1/messages', (req, res) => {
+ // The prompt names the response id, so each call's span can be told apart.
+ const id = req.body.messages[0].content;
+
+ if (!req.body.stream) {
+ res.json({
+ id,
+ type: 'message',
+ role: 'assistant',
+ model: req.body.model,
+ content: [{ type: 'text', text: 'Hello!' }],
+ stop_reason: 'end_turn',
+ stop_sequence: null,
+ usage: { input_tokens: 10, output_tokens: 15 },
+ });
+ return;
+ }
+
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
+ const events = streamEvents(id, req.body.model);
+ 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.post('/anthropic/v1/complete', (req, res) => {
+ res.json({
+ id: req.body.prompt.includes('asResponse') ? 'compl_v0_as_response' : 'compl_v0',
+ type: 'completion',
+ model: req.body.model,
+ completion: 'Hello!',
+ stop_reason: 'stop_sequence',
+ });
+ });
+
+ return new Promise(resolve => {
+ const server = app.listen(0, () => {
+ resolve(server);
+ });
+ });
+}
+
+async function run() {
+ const server = await startMockAnthropicServer();
+
+ await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
+ const client = new Anthropic({
+ apiKey: 'mock-api-key',
+ baseURL: `http://localhost:${server.address().port}/anthropic`,
+ });
+ const model = 'claude-3-haiku-20240307';
+
+ await client.messages.create({ model, max_tokens: 100, messages: [{ role: 'user', content: 'msg_v0_create' }] });
+ await client.beta.messages.create({
+ model,
+ max_tokens: 100,
+ messages: [{ role: 'user', content: 'msg_v0_beta_create' }],
+ });
+
+ const stream = await client.messages.create({
+ model,
+ max_tokens: 100,
+ stream: true,
+ messages: [{ role: 'user', content: 'msg_v0_stream' }],
+ });
+ for await (const _ of stream) {
+ // consume
+ }
+
+ await client.messages
+ .stream({ model, max_tokens: 100, messages: [{ role: 'user', content: 'msg_v0_stream_helper' }] })
+ .finalMessage();
+
+ await client.completions.create({
+ model: 'claude-2',
+ max_tokens_to_sample: 100,
+ prompt: '\n\nHuman: Hello\n\nAssistant:',
+ });
+ const response = await client.completions
+ .create({ model: 'claude-2', max_tokens_to_sample: 100, prompt: '\n\nHuman: asResponse\n\nAssistant:' })
+ .asResponse();
+ await response.json();
+ });
+
+ await Sentry.flush(2000);
+
+ server.close();
+}
+
+run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/v0/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0/test.ts
new file mode 100644
index 000000000000..784d48bbcbd4
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0/test.ts
@@ -0,0 +1,70 @@
+import type { SerializedStreamedSpanContainer } from '@sentry/core';
+import {
+ GEN_AI_REQUEST_MODEL,
+ GEN_AI_RESPONSE_ID,
+ GEN_AI_RESPONSE_STREAMING,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
+} from '@sentry/conventions/attributes';
+import { afterAll, describe, expect } from 'vitest';
+import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner';
+
+type SerializedSpan = SerializedStreamedSpanContainer['items'][number];
+
+const MESSAGE_CALLS = [
+ { id: 'msg_v0_create', streaming: false },
+ { id: 'msg_v0_beta_create', streaming: false },
+ { id: 'msg_v0_stream', streaming: true },
+ { id: 'msg_v0_stream_helper', streaming: true },
+];
+
+// The main suite runs on 1.x. This keeps the last 0.x version covered, including `completions`,
+// which 1.0 removed.
+describe('Anthropic integration (0.x)', () => {
+ afterAll(() => {
+ cleanupChildProcesses();
+ });
+
+ createEsmAndCjsTests(
+ __dirname,
+ 'scenario.mjs',
+ 'instrument.mjs',
+ (createRunner, test) => {
+ test('emits one span per call', async () => {
+ await createRunner()
+ .unordered()
+ .expect({
+ span: container => {
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.anthropic',
+ );
+ expect(spans).toHaveLength(6);
+ for (const span of spans) {
+ expect(span.attributes[SENTRY_OP]?.value, span.name).toBe('gen_ai.chat');
+ expect(span.status, span.name).toBe('ok');
+ }
+
+ const spanForId = (id: string): SerializedSpan[] =>
+ spans.filter(span => span.attributes[GEN_AI_RESPONSE_ID]?.value === id);
+
+ for (const { id, streaming } of MESSAGE_CALLS) {
+ expect(spanForId(id), id).toHaveLength(1);
+ expect(spanForId(id)[0]!.attributes[GEN_AI_RESPONSE_STREAMING]?.value ?? false, id).toBe(streaming);
+ }
+
+ // `.asResponse()` hands back the raw response, so its span has no response id.
+ const completionSpans = spans.filter(span => span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'claude-2');
+ expect(completionSpans.map(span => span.attributes[GEN_AI_RESPONSE_ID]?.value)).toEqual([
+ 'compl_v0',
+ undefined,
+ ]);
+ },
+ })
+ .start()
+ .completed();
+ });
+ },
+ { additionalDependencies: { '@anthropic-ai/sdk': '0.133.0' } },
+ );
+});
diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/v1/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/v1/instrument.mjs
new file mode 100644
index 000000000000..b5472310228b
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/v1/instrument.mjs
@@ -0,0 +1,16 @@
+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,
+ // Registers Sentry as the global tracer provider, which is what 1.x traces itself through.
+ enableOpenTelemetrySetup: process.env.OTEL_SETUP === 'true',
+ integrations:
+ process.env.WITHOUT_ANTHROPIC === 'true'
+ ? integrations => integrations.filter(integration => integration.name !== 'Anthropic_AI')
+ : undefined,
+});
diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/v1/scenario-session-tool-runner.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/v1/scenario-session-tool-runner.mjs
new file mode 100644
index 000000000000..07658baa87c1
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/v1/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/v1/scenario-tool-runner.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/v1/scenario-tool-runner.mjs
new file mode 100644
index 000000000000..b2bce0035220
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/v1/scenario-tool-runner.mjs
@@ -0,0 +1,136 @@
+import Anthropic from '@anthropic-ai/sdk';
+import * as Sentry from '@sentry/node';
+import express from 'express';
+
+const MODEL = 'claude-3-haiku-20240307';
+
+function toolUseEvents() {
+ const events = [
+ {
+ type: 'message_start',
+ message: {
+ id: 'msg_tools',
+ type: 'message',
+ role: 'assistant',
+ model: MODEL,
+ content: [],
+ usage: { input_tokens: 10 },
+ },
+ },
+ ];
+ [
+ { id: 'toolu_weather', name: 'weather', input: { city: 'Vienna' } },
+ { id: 'toolu_explode', name: 'explode', input: {} },
+ ].forEach((call, index) => {
+ events.push(
+ {
+ type: 'content_block_start',
+ index,
+ content_block: { type: 'tool_use', id: call.id, name: call.name, input: {} },
+ },
+ {
+ type: 'content_block_delta',
+ index,
+ delta: { type: 'input_json_delta', partial_json: JSON.stringify(call.input) },
+ },
+ { type: 'content_block_stop', index },
+ );
+ });
+ events.push(
+ { type: 'message_delta', delta: { stop_reason: 'tool_use', stop_sequence: null }, usage: { output_tokens: 20 } },
+ { type: 'message_stop' },
+ );
+ return events;
+}
+
+function finalEvents() {
+ return [
+ {
+ type: 'message_start',
+ message: {
+ id: 'msg_final',
+ type: 'message',
+ role: 'assistant',
+ model: MODEL,
+ content: [],
+ usage: { input_tokens: 30 },
+ },
+ },
+ { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
+ { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Sunny.' } },
+ { type: 'content_block_stop', index: 0 },
+ { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 5 } },
+ { type: 'message_stop' },
+ ];
+}
+
+function startMockAnthropicServer() {
+ const app = express();
+ app.use(express.json());
+
+ let requests = 0;
+ app.post('/anthropic/v1/messages', (_req, res) => {
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
+
+ const events = requests++ === 0 ? toolUseEvents() : finalEvents();
+ 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);
+ });
+ });
+
+ return new Promise(resolve => {
+ const server = app.listen(0, () => {
+ resolve(server);
+ });
+ });
+}
+
+async function run() {
+ const server = await startMockAnthropicServer();
+
+ await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
+ const client = new Anthropic({
+ apiKey: 'mock-api-key',
+ baseURL: `http://localhost:${server.address().port}/anthropic`,
+ });
+
+ const runner = client.beta.messages.toolRunner({
+ model: MODEL,
+ max_tokens: 100,
+ messages: [{ role: 'user', content: 'What is the weather in Vienna?' }],
+ tools: [
+ {
+ name: 'weather',
+ description: 'Get the weather by city',
+ input_schema: { type: 'object', properties: { city: { type: 'string' } } },
+ run: () => Sentry.startSpan({ name: 'weather lookup' }, () => 'sunny'),
+ },
+ {
+ name: 'explode',
+ description: 'Always fails',
+ input_schema: { type: 'object', properties: {} },
+ run: () => {
+ throw new Error('Tool exploded');
+ },
+ },
+ ],
+ stream: true,
+ runToolsEagerly: true,
+ });
+ for await (const stream of runner) {
+ await stream.finalMessage();
+ }
+ });
+
+ await Sentry.flush(2000);
+
+ server.close();
+}
+
+run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/v1/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/v1/scenario.mjs
new file mode 100644
index 000000000000..658486195624
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/v1/scenario.mjs
@@ -0,0 +1,98 @@
+import Anthropic from '@anthropic-ai/sdk';
+import * as Sentry from '@sentry/node';
+import express from 'express';
+
+function streamEvents(id, model) {
+ return [
+ {
+ type: 'message_start',
+ message: { id, type: 'message', role: 'assistant', model, content: [], usage: { input_tokens: 10 } },
+ },
+ { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
+ { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hello!' } },
+ { type: 'content_block_stop', index: 0 },
+ { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 15 } },
+ { type: 'message_stop' },
+ ];
+}
+
+function startMockAnthropicServer() {
+ const app = express();
+ app.use(express.json());
+
+ app.post('/anthropic/v1/messages', (req, res) => {
+ // The prompt names the response id, so each call's span can be told apart.
+ const id = req.body.messages[0].content;
+
+ if (!req.body.stream) {
+ res.json({
+ id,
+ type: 'message',
+ role: 'assistant',
+ model: req.body.model,
+ content: [{ type: 'text', text: 'Hello!' }],
+ stop_reason: 'end_turn',
+ stop_sequence: null,
+ usage: { input_tokens: 10, output_tokens: 15 },
+ });
+ return;
+ }
+
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
+ const events = streamEvents(id, req.body.model);
+ 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);
+ });
+ });
+
+ return new Promise(resolve => {
+ const server = app.listen(0, () => {
+ resolve(server);
+ });
+ });
+}
+
+async function run() {
+ const server = await startMockAnthropicServer();
+
+ await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
+ const client = new Anthropic({
+ apiKey: 'mock-api-key',
+ baseURL: `http://localhost:${server.address().port}/anthropic`,
+ });
+ const model = 'claude-3-haiku-20240307';
+
+ await client.messages.create({ model, max_tokens: 100, messages: [{ role: 'user', content: 'msg_v1_create' }] });
+ await client.beta.messages.create({
+ model,
+ max_tokens: 100,
+ messages: [{ role: 'user', content: 'msg_v1_beta_create' }],
+ });
+
+ const stream = await client.messages.create({
+ model,
+ max_tokens: 100,
+ stream: true,
+ messages: [{ role: 'user', content: 'msg_v1_stream' }],
+ });
+ for await (const _ of stream) {
+ // consume
+ }
+
+ await client.messages
+ .stream({ model, max_tokens: 100, messages: [{ role: 'user', content: 'msg_v1_stream_helper' }] })
+ .finalMessage();
+ });
+
+ await Sentry.flush(2000);
+
+ server.close();
+}
+
+run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/v1/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/v1/test.ts
new file mode 100644
index 000000000000..0983283d178f
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/v1/test.ts
@@ -0,0 +1,158 @@
+import type { Event, SerializedStreamedSpanContainer } from '@sentry/core';
+import {
+ GEN_AI_RESPONSE_ID,
+ GEN_AI_RESPONSE_STREAMING,
+ GEN_AI_TOOL_NAME,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
+} from '@sentry/conventions/attributes';
+import { GEN_AI_EXECUTE_TOOL } from '@sentry/conventions/op';
+import { afterAll, describe, expect } from 'vitest';
+import { GEN_AI_TOOL_CALL_ID_ATTRIBUTE } from '../../../../../../packages/server-utils/src/ai/core/gen-ai-attributes';
+import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner';
+
+type SerializedSpan = SerializedStreamedSpanContainer['items'][number];
+
+const CALLS = [
+ { id: 'msg_v1_create', streaming: false },
+ { id: 'msg_v1_beta_create', streaming: false },
+ { id: 'msg_v1_stream', streaming: true },
+ { id: 'msg_v1_stream_helper', streaming: true },
+];
+
+function spanForCall(spans: SerializedSpan[], id: string): SerializedSpan {
+ const callSpans = spans.filter(span => span.attributes[GEN_AI_RESPONSE_ID]?.value === id);
+ expect(callSpans, id).toHaveLength(1);
+ return callSpans[0]!;
+}
+
+function expectOneSentrySpanPerCall(container: SerializedStreamedSpanContainer): void {
+ const spans = container.items;
+ expect(spans.filter(span => span.name.startsWith('anthropic.'))).toEqual([]);
+
+ for (const { id, streaming } of CALLS) {
+ const chatSpan = spanForCall(spans, id);
+ expect(chatSpan.attributes[SENTRY_ORIGIN]?.value, id).toBe('auto.ai.anthropic');
+ expect(chatSpan.attributes[SENTRY_OP]?.value, id).toBe('gen_ai.chat');
+ expect(chatSpan.attributes[GEN_AI_RESPONSE_STREAMING]?.value ?? false, id).toBe(streaming);
+ }
+}
+
+describe('Anthropic integration (1.x)', () => {
+ afterAll(() => {
+ cleanupChildProcesses();
+ });
+
+ createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
+ test('emits one span per call', async () => {
+ await createRunner().unordered().expect({ span: expectOneSentrySpanPerCall }).start().completed();
+ });
+
+ test('emits one span per call when Sentry is the global tracer provider', async () => {
+ await createRunner()
+ .withEnv({ OTEL_SETUP: 'true' })
+ .unordered()
+ .expect({ span: expectOneSentrySpanPerCall })
+ .start()
+ .completed();
+ });
+
+ test("records Anthropic's own spans when the integration is removed", async () => {
+ await createRunner()
+ .withEnv({ OTEL_SETUP: 'true', WITHOUT_ANTHROPIC: 'true' })
+ .unordered()
+ .expect({
+ span: container => {
+ const spans = container.items;
+ expect(spans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.anthropic')).toEqual([]);
+
+ for (const { id } of CALLS) {
+ expect(spanForCall(spans, id).name, id).toBe('anthropic.messages.create');
+ }
+ },
+ })
+ .start()
+ .completed();
+ });
+ });
+
+ // 1.x makes its tool-runner and tool spans active around each tool run, even when they are no-ops.
+ createEsmAndCjsTests(__dirname, 'scenario-tool-runner.mjs', 'instrument.mjs', (createRunner, test) => {
+ for (const env of [{}, { OTEL_SETUP: 'true' }]) {
+ test(`keeps tool spans under the caller's span with ${JSON.stringify(env)}`, async () => {
+ await createRunner()
+ .withEnv(env)
+ .unordered()
+ .expect({
+ span: container => {
+ const spans = container.items;
+ expect(spans.filter(span => span.name.startsWith('anthropic.'))).toEqual([]);
+
+ const main = spans.find(span => span.is_segment && span.name === 'main')!;
+ const toolSpans = spans.filter(span => span.attributes[SENTRY_OP]?.value === GEN_AI_EXECUTE_TOOL);
+ expect(toolSpans.map(span => span.attributes[GEN_AI_TOOL_NAME]?.value).sort()).toEqual([
+ 'explode',
+ 'weather',
+ ]);
+ for (const toolSpan of toolSpans) {
+ expect(toolSpan.parent_span_id, toolSpan.name).toBe(main.span_id);
+ }
+
+ const weather = toolSpans.find(span => span.attributes[GEN_AI_TOOL_NAME]?.value === 'weather')!;
+ const lookups = spans.filter(span => span.name === 'weather lookup');
+ expect(lookups).toHaveLength(1);
+ expect(lookups[0]!.parent_span_id).toBe(weather.span_id);
+ },
+ })
+ .expect({
+ event: event => {
+ expect(event.exception?.values?.[0]?.value).toBe('Tool exploded');
+ },
+ })
+ .start()
+ .completed();
+ });
+ }
+ });
+
+ createEsmAndCjsTests(__dirname, 'scenario-session-tool-runner.mjs', 'instrument.mjs', (createRunner, test) => {
+ for (const env of [{}, { OTEL_SETUP: 'true' }]) {
+ test(`emits an execute_tool span for each tool a session runner runs with ${JSON.stringify(env)}`, async () => {
+ let receivedEvent: Event | undefined;
+ let receivedSpans: SerializedStreamedSpanContainer['items'] = [];
+
+ await createRunner()
+ .withEnv(env)
+ .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')!;
+ expect(receivedSpans.filter(span => span.name.startsWith('anthropic.'))).toEqual([]);
+ const toolSpans = receivedSpans.filter(span => span.attributes[SENTRY_OP]?.value === GEN_AI_EXECUTE_TOOL);
+ expect(toolSpans.map(span => span.attributes[GEN_AI_TOOL_NAME]?.value).sort()).toEqual([
+ 'explode',
+ 'read',
+ 'weather',
+ ]);
+ for (const toolSpan of toolSpans) {
+ expect(toolSpan.parent_span_id, toolSpan.name).toBe(main.span_id);
+ }
+ const weather = toolSpans.find(span => span.attributes[GEN_AI_TOOL_NAME]?.value === 'weather')!;
+ expect(weather.attributes[GEN_AI_TOOL_CALL_ID_ATTRIBUTE]).toEqual({ type: 'string', value: 'sevt_weather' });
+ expect(receivedEvent?.exception?.values?.[0]?.value).toBe('Tool exploded');
+ });
+ }
+ });
+});
diff --git a/dev-packages/node-integration-tests/suites/tracing/bullmq/docker-compose.yml b/dev-packages/node-integration-tests/suites/tracing/bullmq/docker-compose.yml
new file mode 100644
index 000000000000..deac6f3496a4
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/docker-compose.yml
@@ -0,0 +1,12 @@
+services:
+ db:
+ image: redis:latest
+ restart: always
+ ports:
+ - '6384:6379'
+ healthcheck:
+ test: ['CMD-SHELL', 'redis-cli ping | grep -q PONG']
+ interval: 2s
+ timeout: 3s
+ retries: 30
+ start_period: 5s
diff --git a/dev-packages/node-integration-tests/suites/tracing/bullmq/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/bullmq/instrument.mjs
new file mode 100644
index 000000000000..039b3bdb5104
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/instrument.mjs
@@ -0,0 +1,12 @@
+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,
+ // The Context integration adds machine-specific attributes to segment spans, and adds the `os.*` ones only after an
+ // async lookup. Without it, the tests can compare all span attributes.
+ integrations: integrations => integrations.filter(integration => integration.name !== 'Context'),
+});
diff --git a/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario-attributes.mjs b/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario-attributes.mjs
new file mode 100644
index 000000000000..1d3b1e445320
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario-attributes.mjs
@@ -0,0 +1,68 @@
+import * as Sentry from '@sentry/node';
+import { FlowProducer, Queue, Worker } from 'bullmq';
+
+const telemetry = new Sentry.BullMQTelemetry({ enableMetrics: true });
+const connection = { host: '127.0.0.1', port: 6384 };
+
+async function run() {
+ const opsQueue = new Queue('attributes-ops-queue', { connection, telemetry });
+ const opsWorker = new Worker('attributes-ops-queue', null, { connection, telemetry, name: 'attributes-ops-worker' });
+ const flowProducer = new FlowProducer({ connection, telemetry });
+ const processQueue = new Queue('attributes-process-queue', { connection, telemetry });
+ const processWorker = new Worker('attributes-process-queue', async () => {}, {
+ connection,
+ telemetry,
+ name: 'attributes-worker',
+ });
+
+ const jobProcessed = new Promise(resolve => {
+ processWorker.once('completed', () => resolve());
+ });
+
+ await Sentry.startSpan({ name: 'bullmq attributes' }, async () => {
+ await processQueue.add('process-job', { data: 'test-data' });
+
+ await opsQueue.addBulk([
+ { name: 'bulk-job-1', data: {} },
+ { name: 'bulk-job-2', data: {} },
+ ]);
+ await flowProducer.add({
+ name: 'flow-root',
+ queueName: 'attributes-ops-queue',
+ children: [{ name: 'flow-child', queueName: 'attributes-ops-queue' }],
+ });
+ await opsQueue.upsertJobScheduler('attributes-scheduler', { every: 60_000 }, { name: 'scheduled-job' });
+
+ const progressJob = await opsQueue.add('progress-job', {});
+ await opsQueue.updateJobProgress(progressJob.id, { percentage: 50 });
+ await opsQueue.remove(progressJob.id, { removeChildren: true });
+ await opsQueue.removeDeduplicationKey('attributes-deduplication-id');
+ await opsQueue.removeDebounceKey('attributes-debounce-id');
+ await opsQueue.retryJobs({ count: 10 });
+ await opsQueue.promoteJobs({ count: 10 });
+ await opsQueue.trimEvents(100);
+ await opsQueue.clean(0, 100, 'completed');
+
+ await opsWorker.getNextJob('attributes-token', { block: false });
+ // BullMQ runs these two on timers. Calling them directly runs them once, inside this span.
+ await opsWorker['moveStalledJobsToWait']();
+ await opsWorker['lockManager'].extendLocks([]);
+ await opsWorker.rateLimit(1);
+ await opsWorker.pause(true);
+ await opsWorker.close(true);
+
+ await opsQueue.rateLimit(1);
+ await opsQueue.drain(true);
+ });
+
+ await jobProcessed;
+ await processQueue.recordJobCountsMetric('waiting', 'completed');
+ await processWorker.close();
+
+ await opsQueue.obliterate({ force: true });
+ await processQueue.obliterate({ force: true });
+ await Promise.all([flowProducer.close(), opsQueue.close(), processQueue.close()]);
+ await Sentry.flush();
+}
+
+run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario-failed-job.mjs b/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario-failed-job.mjs
new file mode 100644
index 000000000000..90eea3bb176e
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario-failed-job.mjs
@@ -0,0 +1,30 @@
+import * as Sentry from '@sentry/node';
+import { Queue, Worker } from 'bullmq';
+
+const telemetry = new Sentry.BullMQTelemetry();
+const connection = { host: '127.0.0.1', port: 6384 };
+
+async function run() {
+ const queue = new Queue('test-failed-queue', { connection, telemetry });
+
+ const worker = new Worker(
+ 'test-failed-queue',
+ async () => {
+ throw new Error('Job failed on purpose');
+ },
+ { connection, telemetry },
+ );
+
+ const jobFailed = new Promise(resolve => {
+ worker.on('failed', () => resolve());
+ });
+
+ await queue.add('failing-job', { data: 'test-data' });
+
+ await jobFailed;
+ await worker.close();
+ await queue.close();
+ await Sentry.flush();
+}
+
+run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario.mjs
new file mode 100644
index 000000000000..ffdcead133e6
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario.mjs
@@ -0,0 +1,39 @@
+import * as Sentry from '@sentry/node';
+import { Queue, Worker } from 'bullmq';
+
+const telemetry = new Sentry.BullMQTelemetry({ enableMetrics: true });
+const connection = { host: '127.0.0.1', port: 6384 };
+
+async function run() {
+ const queue = new Queue('test-queue', { connection, telemetry });
+
+ const worker = new Worker(
+ 'test-queue',
+ async () => {
+ // job processed
+ },
+ { connection, telemetry },
+ );
+
+ const jobsProcessed = new Promise(resolve => {
+ let completed = 0;
+ worker.on('completed', () => {
+ completed++;
+ if (completed === 2) {
+ resolve();
+ }
+ });
+ });
+
+ await Sentry.startSpan({ name: 'enqueue test-jobs' }, async () => {
+ await queue.add('test-job-1', { data: 'test-data' });
+ await queue.add('test-job-2', { data: 'test-data' });
+ });
+
+ await jobsProcessed;
+ await worker.close();
+ await queue.close();
+ await Sentry.flush();
+}
+
+run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts
new file mode 100644
index 000000000000..49871dcc9786
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts
@@ -0,0 +1,781 @@
+import type { Event, SerializedMetric } from '@sentry/core';
+import { SDK_VERSION } from '@sentry/core';
+import { afterAll, expect } from 'vitest';
+import { EXPECTED_SDK_NAME } from '../../../utils';
+import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner';
+
+describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => {
+ afterAll(() => {
+ cleanupChildProcesses();
+ });
+
+ createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
+ test('traces producer and consumer operations with queue attributes', { timeout: 90_000 }, async () => {
+ const runner = createRunner().ignore('trace_metric');
+ const producerSpansPromise = runner.collectStreamedSpansUntilSegment('enqueue test-jobs');
+ const consumer1SpansPromise = runner.collectStreamedSpansUntilSegment(
+ span => span.attributes['messaging.bullmq.job.name']?.value === 'test-job-1',
+ );
+ const consumer2SpansPromise = runner.collectStreamedSpansUntilSegment(
+ span => span.attributes['messaging.bullmq.job.name']?.value === 'test-job-2',
+ );
+
+ await runner.start().completed();
+
+ const producerSpans = await producerSpansPromise;
+ const consumerSpans = [await consumer1SpansPromise, await consumer2SpansPromise];
+ const producerSegment = producerSpans.find(span => span.is_segment && span.name === 'enqueue test-jobs')!;
+ const publishSpans = producerSpans
+ .filter(span => span.attributes['messaging.system']?.value === 'bullmq')
+ .sort((a, b) => a.start_timestamp - b.start_timestamp);
+ const consumerSegments = consumerSpans.map(spans =>
+ spans.find(span => span.is_segment && span.name === 'process test-queue')!,
+ );
+
+ expect(
+ publishSpans.map(span => ({
+ name: span.name,
+ parent_span_id: span.parent_span_id,
+ status: span.status,
+ attributes: span.attributes,
+ })),
+ ).toEqual(
+ ['test-job-1', 'test-job-2'].map(jobName => ({
+ name: `add test-queue.${jobName}`,
+ parent_span_id: producerSegment.span_id,
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue.publish' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.producer' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'test-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'add' },
+ 'messaging.message.id': { type: 'string', value: expect.any(String) },
+ 'messaging.bullmq.job.name': { type: 'string', value: jobName },
+ 'sentry.environment': { type: 'string', value: 'production' },
+ 'sentry.is_localhost': { type: 'boolean', value: false },
+ '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.segment.id': { type: 'string', value: producerSegment.span_id },
+ 'sentry.segment.name': { type: 'string', value: 'enqueue test-jobs' },
+ 'sentry.trace_lifecycle': { type: 'string', value: 'stream' },
+ },
+ })),
+ );
+
+ expect(
+ consumerSpans.map(spans =>
+ spans
+ .filter(span => span.attributes['messaging.system']?.value === 'bullmq')
+ .sort((a, b) => a.start_timestamp - b.start_timestamp)
+ .map(span => ({
+ name: span.name,
+ is_segment: span.is_segment,
+ status: span.status,
+ attributes: span.attributes,
+ })),
+ ),
+ ).toEqual(
+ ['test-job-1', 'test-job-2'].map((jobName, index) => [
+ {
+ name: 'process test-queue',
+ is_segment: true,
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue.process' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.consumer' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'test-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'process' },
+ 'messaging.message.id': { type: 'string', value: expect.any(String) },
+ 'messaging.message.retry.count': { type: 'integer', value: 0 },
+ 'messaging.client.id': { type: 'string', value: expect.any(String) },
+ 'messaging.bullmq.job.name': { type: 'string', value: jobName },
+ 'messaging.bullmq.job.processed_timestamp': { type: 'integer', value: expect.any(Number) },
+ 'messaging.bullmq.job.attempt_finished_timestamp': { type: 'integer', value: expect.any(Number) },
+ 'sentry.environment': { type: 'string', value: 'production' },
+ 'sentry.is_localhost': { type: 'boolean', value: false },
+ '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.segment.id': { type: 'string', value: consumerSegments[index]!.span_id },
+ 'sentry.segment.name': { type: 'string', value: 'process test-queue' },
+ 'sentry.trace_lifecycle': { type: 'string', value: 'stream' },
+ 'sentry.sample_rate': { type: 'integer', value: 1 },
+ 'sentry.sdk.integrations': { type: 'array', value: expect.any(Array) },
+ 'sentry.segment.name.source': { type: 'string', value: 'custom' },
+ },
+ },
+ {
+ name: 'complete test-queue',
+ is_segment: false,
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'test-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'complete' },
+ 'messaging.message.id': { type: 'string', value: expect.any(String) },
+ 'messaging.bullmq.job.name': { type: 'string', value: jobName },
+ 'sentry.environment': { type: 'string', value: 'production' },
+ 'sentry.is_localhost': { type: 'boolean', value: false },
+ '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.segment.id': { type: 'string', value: consumerSegments[index]!.span_id },
+ 'sentry.segment.name': { type: 'string', value: 'process test-queue' },
+ 'sentry.trace_lifecycle': { type: 'string', value: 'stream' },
+ },
+ },
+ ]),
+ );
+
+ expect(consumerSegments.map(segment => segment.attributes['messaging.message.id'])).toEqual(
+ publishSpans.map(span => span.attributes['messaging.message.id']),
+ );
+ expect(consumerSegments.map(segment => segment.links)).toEqual(
+ publishSpans.map(span => [
+ {
+ trace_id: span.trace_id,
+ span_id: span.span_id,
+ sampled: true,
+ attributes: { 'sentry.link.type': { type: 'string', value: 'message_origin' } },
+ },
+ ]),
+ );
+ expect(new Set([producerSegment.trace_id, ...consumerSegments.map(segment => segment.trace_id)]).size).toBe(3);
+ });
+
+ test('emits completion counter and duration histogram for processed jobs', { timeout: 90_000 }, async () => {
+ let metrics: SerializedMetric[] = [];
+
+ await createRunner()
+ .ignore('span')
+ .expect({
+ trace_metric: container => {
+ metrics = container.items;
+ },
+ })
+ .start()
+ .completed();
+
+ expect(
+ metrics.map(metric => ({
+ name: metric.name,
+ type: metric.type,
+ unit: metric.unit,
+ value: metric.value,
+ attributes: metric.attributes,
+ })),
+ ).toEqual(
+ ['test-job-1', 'test-job-2'].flatMap(jobName => [
+ {
+ name: 'messaging.bullmq.jobs.completed',
+ type: 'counter',
+ unit: 'none',
+ value: 1,
+ attributes: {
+ 'messaging.destination.name': { type: 'string', value: 'test-queue' },
+ 'messaging.bullmq.job.name': { type: 'string', value: jobName },
+ 'messaging.bullmq.job.state': { type: 'string', value: 'completed' },
+ '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.timestamp.sequence': { type: 'integer', value: expect.any(Number) },
+ 'server.address': { type: 'string', value: expect.any(String) },
+ },
+ },
+ {
+ name: 'messaging.process.duration',
+ type: 'distribution',
+ unit: 'second',
+ value: expect.any(Number),
+ attributes: {
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.operation.name': { type: 'string', value: 'process' },
+ 'messaging.destination.name': { type: 'string', value: 'test-queue' },
+ 'messaging.bullmq.job.name': { type: 'string', value: jobName },
+ 'messaging.bullmq.job.state': { type: 'string', value: 'completed' },
+ '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.timestamp.sequence': { type: 'integer', value: expect.any(Number) },
+ 'server.address': { type: 'string', value: expect.any(String) },
+ },
+ },
+ ]),
+ );
+ });
+ });
+
+ createEsmAndCjsTests(__dirname, 'scenario-failed-job.mjs', 'instrument.mjs', (createRunner, test) => {
+ test(
+ 'captures an error for a failed job and sets the error status on its process span',
+ { timeout: 90_000 },
+ async () => {
+ let errorEvent: Event | undefined;
+ const runner = createRunner().ignore('trace_metric');
+ const consumerSpansPromise = runner.collectStreamedSpansUntilSegment('process test-failed-queue');
+
+ await runner
+ .expect({
+ event: event => {
+ errorEvent = event;
+ },
+ })
+ .start()
+ .completed();
+
+ const consumerSpans = await consumerSpansPromise;
+ const processSpan = consumerSpans.find(span => span.is_segment && span.name === 'process test-failed-queue')!;
+
+ expect(
+ consumerSpans
+ .filter(span => span.attributes['messaging.system']?.value === 'bullmq')
+ .sort((a, b) => a.start_timestamp - b.start_timestamp)
+ .map(span => ({
+ name: span.name,
+ status: span.status,
+ attributes: span.attributes,
+ })),
+ ).toEqual([
+ {
+ name: 'process test-failed-queue',
+ status: 'error',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue.process' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.consumer' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'test-failed-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'process' },
+ 'messaging.message.id': { type: 'string', value: expect.any(String) },
+ 'messaging.message.retry.count': { type: 'integer', value: 0 },
+ 'messaging.client.id': { type: 'string', value: expect.any(String) },
+ 'messaging.bullmq.job.name': { type: 'string', value: 'failing-job' },
+ 'messaging.bullmq.job.processed_timestamp': { type: 'integer', value: expect.any(Number) },
+ 'messaging.bullmq.job.attempt_finished_timestamp': { type: 'integer', value: expect.any(Number) },
+ 'sentry.environment': { type: 'string', value: 'production' },
+ 'sentry.is_localhost': { type: 'boolean', value: false },
+ '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.segment.id': { type: 'string', value: processSpan.span_id },
+ 'sentry.segment.name': { type: 'string', value: 'process test-failed-queue' },
+ 'sentry.trace_lifecycle': { type: 'string', value: 'stream' },
+ 'sentry.sample_rate': { type: 'integer', value: 1 },
+ 'sentry.sdk.integrations': { type: 'array', value: expect.any(Array) },
+ 'sentry.segment.name.source': { type: 'string', value: 'custom' },
+ 'sentry.status.message': { type: 'string', value: 'internal_error' },
+ },
+ },
+ {
+ name: 'fail test-failed-queue',
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'test-failed-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'fail' },
+ 'messaging.message.id': { type: 'string', value: expect.any(String) },
+ 'messaging.bullmq.job.name': { type: 'string', value: 'failing-job' },
+ 'sentry.environment': { type: 'string', value: 'production' },
+ 'sentry.is_localhost': { type: 'boolean', value: false },
+ '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.segment.id': { type: 'string', value: processSpan.span_id },
+ 'sentry.segment.name': { type: 'string', value: 'process test-failed-queue' },
+ 'sentry.trace_lifecycle': { type: 'string', value: 'stream' },
+ },
+ },
+ ]);
+ expect(errorEvent?.exception?.values).toHaveLength(1);
+ expect(errorEvent?.exception?.values?.[0]?.type).toBe('Error');
+ expect(errorEvent?.exception?.values?.[0]?.value).toBe('Job failed on purpose');
+ expect(errorEvent?.exception?.values?.[0]?.mechanism).toEqual({ type: 'auto.queue.bullmq', handled: false });
+ expect(errorEvent?.exception?.values?.[0]?.stacktrace).toBeUndefined();
+ expect(errorEvent?.contexts?.trace?.trace_id).toBe(processSpan.trace_id);
+ expect(errorEvent?.contexts?.trace?.span_id).toBe(processSpan.span_id);
+ },
+ );
+ });
+
+ createEsmAndCjsTests(__dirname, 'scenario-attributes.mjs', 'instrument.mjs', (createRunner, test) => {
+ test('sends the attributes and metrics of all BullMQ operations', { timeout: 90_000 }, async () => {
+ let metrics: SerializedMetric[] = [];
+ const runner = createRunner();
+ const opsSpansPromise = runner.collectStreamedSpansUntilSegment('bullmq attributes');
+ const processSpansPromise = runner.collectStreamedSpansUntilSegment('process attributes-process-queue');
+
+ await runner
+ .expect({
+ trace_metric: container => {
+ metrics = container.items;
+ },
+ })
+ .start()
+ .completed();
+
+ const opsSpans = await opsSpansPromise;
+ const processSpans = await processSpansPromise;
+ const opsSegment = opsSpans.find(span => span.is_segment && span.name === 'bullmq attributes')!;
+ const processSegment = processSpans.find(
+ span => span.is_segment && span.name === 'process attributes-process-queue',
+ )!;
+
+ expect(
+ opsSpans
+ .filter(span => span.attributes['messaging.system']?.value === 'bullmq')
+ .sort((a, b) => a.start_timestamp - b.start_timestamp)
+ .map(span => ({
+ name: span.name,
+ status: span.status,
+ attributes: span.attributes,
+ })),
+ ).toEqual(
+ [
+ {
+ name: 'add attributes-process-queue.process-job',
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue.publish' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.producer' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'attributes-process-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'add' },
+ 'messaging.message.id': { type: 'string', value: expect.any(String) },
+ 'messaging.bullmq.job.name': { type: 'string', value: 'process-job' },
+ },
+ },
+ {
+ name: 'addBulk attributes-ops-queue',
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue.publish' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.producer' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'addBulk' },
+ 'messaging.batch.message_count': { type: 'integer', value: 2 },
+ 'messaging.bullmq.job.bulk.names': { type: 'array', value: ['bulk-job-1', 'bulk-job-2'] },
+ },
+ },
+ {
+ name: 'addFlow attributes-ops-queue',
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue.publish' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.producer' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'addFlow' },
+ 'messaging.bullmq.flow.name': { type: 'string', value: 'flow-root' },
+ },
+ },
+ {
+ name: 'addNode attributes-ops-queue',
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'addNode' },
+ 'messaging.message.id': { type: 'string', value: expect.any(String) },
+ 'messaging.bullmq.job.name': { type: 'string', value: 'flow-root' },
+ },
+ },
+ {
+ name: 'addNode attributes-ops-queue',
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'addNode' },
+ 'messaging.message.id': { type: 'string', value: expect.any(String) },
+ 'messaging.bullmq.job.name': { type: 'string', value: 'flow-child' },
+ },
+ },
+ {
+ name: 'add attributes-ops-queue.scheduled-job',
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue.publish' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.producer' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'add' },
+ 'messaging.message.id': {
+ type: 'string',
+ value: expect.stringMatching(/^repeat:attributes-scheduler:\d+$/),
+ },
+ 'messaging.bullmq.job.scheduler.id': { type: 'string', value: 'attributes-scheduler' },
+ },
+ },
+ {
+ name: 'add attributes-ops-queue.progress-job',
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue.publish' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.producer' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'add' },
+ 'messaging.message.id': { type: 'string', value: expect.any(String) },
+ 'messaging.bullmq.job.name': { type: 'string', value: 'progress-job' },
+ },
+ },
+ {
+ name: 'updateJobProgress attributes-ops-queue',
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'updateJobProgress' },
+ 'messaging.message.id': { type: 'string', value: expect.any(String) },
+ },
+ },
+ {
+ name: 'remove attributes-ops-queue',
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'remove' },
+ 'messaging.message.id': { type: 'string', value: expect.any(String) },
+ },
+ },
+ {
+ name: 'removeDeduplicationKey attributes-ops-queue',
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'removeDeduplicationKey' },
+ 'messaging.bullmq.job.deduplication.key': { type: 'string', value: 'attributes-deduplication-id' },
+ },
+ },
+ {
+ name: 'removeDebounceKey attributes-ops-queue',
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'removeDebounceKey' },
+ 'messaging.bullmq.job.key': { type: 'string', value: 'attributes-debounce-id' },
+ },
+ },
+ {
+ name: 'retryJobs attributes-ops-queue',
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'retryJobs' },
+ },
+ },
+ {
+ name: 'promoteJobs attributes-ops-queue',
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'promoteJobs' },
+ },
+ },
+ {
+ name: 'trimEvents attributes-ops-queue',
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'trimEvents' },
+ 'messaging.bullmq.queue.event.max_length': { type: 'integer', value: 100 },
+ },
+ },
+ {
+ // `messaging.bullmq.queue.clean.count` replaces `messaging.bullmq.job.ids` in BullMQ 6.
+ name: 'clean attributes-ops-queue',
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'clean' },
+ 'messaging.bullmq.job.type': { type: 'string', value: 'completed' },
+ 'messaging.bullmq.job.ids': { type: 'array', value: [] },
+ 'messaging.bullmq.queue.clean.limit': { type: 'integer', value: 100 },
+ 'messaging.bullmq.queue.grace': { type: 'integer', value: 0 },
+ },
+ },
+ {
+ name: 'getNextJob attributes-ops-queue',
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'getNextJob' },
+ 'messaging.message.id': { type: 'string', value: expect.any(String) },
+ 'messaging.client.id': { type: 'string', value: expect.any(String) },
+ 'messaging.bullmq.worker.name': { type: 'string', value: 'attributes-ops-worker' },
+ },
+ },
+ {
+ name: 'moveStalledJobsToWait attributes-ops-queue',
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'moveStalledJobsToWait' },
+ 'messaging.client.id': { type: 'string', value: expect.any(String) },
+ 'messaging.bullmq.worker.name': { type: 'string', value: 'attributes-ops-worker' },
+ 'messaging.bullmq.worker.stalled_jobs': { type: 'array', value: [] },
+ },
+ },
+ {
+ name: 'extendLocks attributes-ops-queue',
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'extendLocks' },
+ 'messaging.client.id': { type: 'string', value: expect.any(String) },
+ 'messaging.bullmq.worker.name': { type: 'string', value: 'attributes-ops-worker' },
+ 'messaging.bullmq.worker.jobs_to_extend_locks': { type: 'array', value: [] },
+ },
+ },
+ {
+ name: 'rateLimit attributes-ops-queue',
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'rateLimit' },
+ 'messaging.client.id': { type: 'string', value: expect.any(String) },
+ 'messaging.bullmq.worker.rate_limit': { type: 'integer', value: 1 },
+ },
+ },
+ {
+ name: 'pause attributes-ops-queue',
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'pause' },
+ 'messaging.client.id': { type: 'string', value: expect.any(String) },
+ 'messaging.bullmq.worker.name': { type: 'string', value: 'attributes-ops-worker' },
+ 'messaging.bullmq.worker.do_not_wait_active': { type: 'boolean', value: true },
+ },
+ },
+ {
+ name: 'close attributes-ops-queue',
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'close' },
+ 'messaging.client.id': { type: 'string', value: expect.any(String) },
+ 'messaging.bullmq.worker.name': { type: 'string', value: 'attributes-ops-worker' },
+ 'messaging.bullmq.worker.force_close': { type: 'boolean', value: true },
+ },
+ },
+ {
+ name: 'rateLimit attributes-ops-queue',
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'rateLimit' },
+ 'messaging.bullmq.queue.rate_limit': { type: 'integer', value: 1 },
+ },
+ },
+ {
+ name: 'drain attributes-ops-queue',
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'drain' },
+ 'messaging.bullmq.queue.drain.delay': { type: 'boolean', value: true },
+ },
+ },
+ ].map(span => ({
+ ...span,
+ attributes: {
+ ...span.attributes,
+ 'sentry.environment': { type: 'string', value: 'production' },
+ 'sentry.is_localhost': { type: 'boolean', value: false },
+ '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.segment.id': { type: 'string', value: opsSegment.span_id },
+ 'sentry.segment.name': { type: 'string', value: 'bullmq attributes' },
+ 'sentry.trace_lifecycle': { type: 'string', value: 'stream' },
+ },
+ })),
+ );
+
+ expect(
+ processSpans
+ .filter(span => span.attributes['messaging.system']?.value === 'bullmq')
+ .sort((a, b) => a.start_timestamp - b.start_timestamp)
+ .map(span => ({
+ name: span.name,
+ status: span.status,
+ attributes: span.attributes,
+ })),
+ ).toEqual([
+ {
+ name: 'process attributes-process-queue',
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue.process' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.consumer' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'attributes-process-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'process' },
+ 'messaging.message.id': { type: 'string', value: expect.any(String) },
+ 'messaging.message.retry.count': { type: 'integer', value: 0 },
+ 'messaging.client.id': { type: 'string', value: expect.any(String) },
+ 'messaging.bullmq.worker.name': { type: 'string', value: 'attributes-worker' },
+ 'messaging.bullmq.job.name': { type: 'string', value: 'process-job' },
+ 'messaging.bullmq.job.processed_timestamp': { type: 'integer', value: expect.any(Number) },
+ 'messaging.bullmq.job.attempt_finished_timestamp': { type: 'integer', value: expect.any(Number) },
+ 'sentry.environment': { type: 'string', value: 'production' },
+ 'sentry.is_localhost': { type: 'boolean', value: false },
+ '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.segment.id': { type: 'string', value: processSegment.span_id },
+ 'sentry.segment.name': { type: 'string', value: 'process attributes-process-queue' },
+ 'sentry.trace_lifecycle': { type: 'string', value: 'stream' },
+ 'sentry.sample_rate': { type: 'integer', value: 1 },
+ 'sentry.sdk.integrations': { type: 'array', value: expect.any(Array) },
+ 'sentry.segment.name.source': { type: 'string', value: 'custom' },
+ },
+ },
+ {
+ name: 'complete attributes-process-queue',
+ status: 'ok',
+ attributes: {
+ 'sentry.op': { type: 'string', value: 'queue' },
+ 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' },
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.destination.name': { type: 'string', value: 'attributes-process-queue' },
+ 'messaging.operation.name': { type: 'string', value: 'complete' },
+ 'messaging.message.id': { type: 'string', value: expect.any(String) },
+ 'messaging.bullmq.job.name': { type: 'string', value: 'process-job' },
+ 'sentry.environment': { type: 'string', value: 'production' },
+ 'sentry.is_localhost': { type: 'boolean', value: false },
+ '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.segment.id': { type: 'string', value: processSegment.span_id },
+ 'sentry.segment.name': { type: 'string', value: 'process attributes-process-queue' },
+ 'sentry.trace_lifecycle': { type: 'string', value: 'stream' },
+ },
+ },
+ ]);
+
+ expect(
+ metrics.map(metric => ({
+ name: metric.name,
+ type: metric.type,
+ unit: metric.unit,
+ value: metric.value,
+ attributes: metric.attributes,
+ })),
+ ).toEqual([
+ {
+ name: 'messaging.bullmq.jobs.completed',
+ type: 'counter',
+ unit: 'none',
+ value: 1,
+ attributes: {
+ 'messaging.destination.name': { type: 'string', value: 'attributes-process-queue' },
+ 'messaging.bullmq.job.name': { type: 'string', value: 'process-job' },
+ 'messaging.bullmq.job.state': { type: 'string', value: 'completed' },
+ '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.timestamp.sequence': { type: 'integer', value: expect.any(Number) },
+ 'server.address': { type: 'string', value: expect.any(String) },
+ },
+ },
+ {
+ name: 'messaging.process.duration',
+ type: 'distribution',
+ unit: 'second',
+ value: expect.any(Number),
+ attributes: {
+ 'messaging.system': { type: 'string', value: 'bullmq' },
+ 'messaging.operation.name': { type: 'string', value: 'process' },
+ 'messaging.destination.name': { type: 'string', value: 'attributes-process-queue' },
+ 'messaging.bullmq.job.name': { type: 'string', value: 'process-job' },
+ 'messaging.bullmq.job.state': { type: 'string', value: 'completed' },
+ '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.timestamp.sequence': { type: 'integer', value: expect.any(Number) },
+ 'server.address': { type: 'string', value: expect.any(String) },
+ },
+ },
+ ...[
+ { state: 'waiting', value: 0 },
+ { state: 'completed', value: 1 },
+ { state: 'paused', value: 0 },
+ ].map(({ state, value }) => ({
+ name: 'messaging.bullmq.queue.jobs',
+ type: 'gauge',
+ unit: 'none',
+ value,
+ attributes: {
+ 'messaging.destination.name': { type: 'string', value: 'attributes-process-queue' },
+ 'messaging.bullmq.queue.jobs.state': { type: 'string', value: state },
+ '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.timestamp.sequence': { type: 'integer', value: expect.any(Number) },
+ 'server.address': { type: 'string', value: expect.any(String) },
+ },
+ })),
+ ]);
+ });
+ });
+});
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 b750ee653e4f..83c73526b43e 100644
--- a/dev-packages/node-integration-tests/suites/tracing/flue/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/flue/test.ts
@@ -14,198 +14,156 @@ import {
GEN_AI_USAGE_INPUT_TOKENS,
GEN_AI_USAGE_OUTPUT_TOKENS,
GEN_AI_USAGE_TOTAL_TOKENS,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
} from '@sentry/conventions/attributes';
+import { GEN_AI_CHAT, GEN_AI_EXECUTE_TOOL, GEN_AI_INVOKE_AGENT } from '@sentry/conventions/op';
import { afterAll, describe, expect } from 'vitest';
import { supports } from '../../../utils';
-import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
-
-// `@flue/runtime` declares `engines.node >= 22.19`, so it can't live in the package's root
-// `devDependencies` (that would break `yarn install` on the 20.19 CI matrix). Install it per-suite
-// instead, guarded by the `min: 22` skip below.
-const FLUE_DEPENDENCIES = {
- additionalDependencies: {
- '@flue/runtime': '2.0.3',
- '@earendil-works/pi-ai': '0.85.1',
- valibot: '1.1.0',
- },
-};
+import { cleanupChildProcesses, createEsmTests } from '../../../utils/runner';
+// `@flue/runtime` declares `engines.node >= 22.19`.
describe.runIf(supports({ min: 22 }))('Flue integration', () => {
afterAll(() => {
cleanupChildProcesses();
});
- createEsmAndCjsTests(
- __dirname,
- 'scenario.mjs',
- 'instrument.mjs',
- (createRunner, test, mode) => {
- // `@flue/runtime` is ESM-only — its `exports` map has no `require` condition, so there is no
- // CJS variant of this scenario to run.
- if (mode === 'cjs') {
- return;
- }
-
- test('creates the invoke_agent / chat / execute_tool hierarchy', async () => {
- await createRunner()
- .expect({
- span: container => {
- const spans = container.items;
-
- const root = spans.find(span => span.name === 'flue-test')!;
- expect(root.is_segment).toBe(true);
-
- // Counted rather than looked up: the interceptor skips the submission wrapper
- // operation, so one dispatch opens exactly one agent span, and each turn and tool call
- // is spanned once. `find` passes just as happily on a duplicate.
- expect(spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.ai.flue')).toHaveLength(4);
-
- const agents = spans.filter(span => span.name === 'invoke_agent Hello');
- const chats = spans.filter(span => span.name === 'chat faux-model');
- const tools = spans.filter(span => span.name === 'execute_tool get_weather');
- expect(agents).toHaveLength(1);
- expect(tools).toHaveLength(1);
- // One turn asks for the tool, the second answers with its result.
- expect(chats).toHaveLength(2);
-
- const agent = agents[0]!;
- expect(agent.attributes['sentry.op']?.value).toBe('gen_ai.invoke_agent');
- expect(agent.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('invoke_agent');
- expect(agent.attributes[GEN_AI_AGENT_NAME]?.value).toBe('Hello');
- expect(agent.parent_span_id).toBe(root.span_id);
-
- const conversationId = agent.attributes[GEN_AI_CONVERSATION_ID]?.value;
- expect(conversationId).toEqual(expect.any(String));
-
- // Both turns, not just the first: they leave the provider by different paths (a tool
- // call, then a final answer) and resolve their parent through separate tracker lookups.
- for (const chat of chats) {
- expect(chat.attributes['sentry.op']?.value).toBe('gen_ai.chat');
- expect(chat.attributes['sentry.origin']?.value).toBe('auto.ai.flue');
- expect(chat.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('chat');
- expect(chat.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(conversationId);
- expect(chat.parent_span_id).toBe(agent.span_id);
- expect(chat.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBeGreaterThan(0);
- expect(chat.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBeGreaterThan(0);
- expect(chat.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBeGreaterThan(0);
- // Flue computes cost itself; no provider SDK reports it. The faux provider prices
- // every model at zero, so this only proves the attribute is mapped.
- expect(chat.attributes[GEN_AI_COST_TOTAL_TOKENS]?.value).toEqual(expect.any(Number));
- }
-
- // pi-ai's `toolUse` is reported as the conventions' `tool_call`.
- expect(chats.map(chat => chat.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value).sort()).toEqual([
- '["stop"]',
- '["tool_call"]',
- ]);
-
- const tool = tools[0]!;
- // The tool has to actually run: a schema mismatch still produces a correctly named and
- // parented span, so only the status separates a real call from a rejected one.
- expect(tool.status).toBe('ok');
- expect(tool.attributes['sentry.op']?.value).toBe('gen_ai.execute_tool');
- expect(tool.attributes['sentry.origin']?.value).toBe('auto.ai.flue');
- expect(tool.attributes[GEN_AI_TOOL_NAME]?.value).toBe('get_weather');
-
- // Tool spans are siblings of `chat` under the agent invocation, matching how Flue's
- // own OpenTelemetry adapter projects them.
- expect(tool.parent_span_id).toBe(agent.span_id);
- },
- })
- .start()
- .completed();
- });
- },
- FLUE_DEPENDENCIES,
- );
-
- createEsmAndCjsTests(
- __dirname,
- 'scenario.mjs',
- 'instrument-with-pii.mjs',
- (createRunner, test, mode) => {
- if (mode === 'cjs') {
- return;
- }
-
- test('records the messages of the turns in the gen_ai conventions shape', async () => {
- await createRunner()
- .expect({
- span: container => {
- const chats = container.items
- .filter(span => span.name === 'chat faux-model')
- .sort((a, b) => a.start_timestamp - b.start_timestamp);
- expect(chats).toHaveLength(2);
-
- // Flue sends pi-ai's shape (`toolResult`, `toolCall`); Sentry renders the conventions.
- expect(JSON.parse(String(chats[0]!.attributes[GEN_AI_INPUT_MESSAGES]?.value))).toEqual([
- { role: 'user', parts: [{ type: 'text', content: 'What is the weather in Berlin?' }] },
- ]);
- expect(JSON.parse(String(chats[0]!.attributes[GEN_AI_OUTPUT_MESSAGES]?.value))).toEqual([
- {
- role: 'assistant',
- parts: [{ type: 'tool_call', id: 'call_1', name: 'get_weather', arguments: '{"city":"Berlin"}' }],
- finish_reason: 'tool_call',
- },
- ]);
-
- const answerInput = JSON.parse(String(chats[1]!.attributes[GEN_AI_INPUT_MESSAGES]?.value));
- expect(answerInput.map((message: { role: string }) => message.role)).toEqual([
- 'user',
- 'assistant',
- 'tool',
- ]);
- expect(answerInput[2].parts[0]).toMatchObject({
- type: 'tool_call_response',
- id: 'call_1',
- name: 'get_weather',
- });
- expect(answerInput[2].parts[0].result).toContain('sunny in Berlin');
- expect(chats[1]!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]?.value).toContain('You are a helpful assistant.');
-
- // The tool span records the result the model receives, not Flue's internal wrapper.
- const tool = container.items.find(span => span.name === 'execute_tool get_weather')!;
- expect(tool.attributes[GEN_AI_TOOL_CALL_ARGUMENTS]?.value).toBe('{"city":"Berlin"}');
- expect(tool.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toContain('sunny in Berlin');
- expect(tool.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).not.toContain('details');
- },
- })
- .start()
- .completed();
- });
- },
- 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');
+ // `@flue/runtime` is ESM-only: its `exports` map has no `require` condition.
+ createEsmTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
+ test('creates the invoke_agent / chat / execute_tool hierarchy', async () => {
+ await createRunner()
+ .expect({
+ span: container => {
+ const spans = container.items;
+
+ const root = spans.find(span => span.name === 'flue-test')!;
+ expect(root.is_segment).toBe(true);
+
+ // Counted rather than looked up: the interceptor skips the submission wrapper
+ // operation, so one dispatch opens exactly one agent span, and each turn and tool call
+ // is spanned once. `find` passes just as happily on a duplicate.
+ expect(spans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.flue')).toHaveLength(4);
+
+ const agents = spans.filter(span => span.name === 'invoke_agent Hello');
+ const chats = spans.filter(span => span.name === 'chat faux-model');
+ const tools = spans.filter(span => span.name === 'execute_tool get_weather');
expect(agents).toHaveLength(1);
- expect(agents[0]!.parent_span_id).toBe(request.span_id);
+ expect(tools).toHaveLength(1);
+ // One turn asks for the tool, the second answers with its result.
+ expect(chats).toHaveLength(2);
+
+ const agent = agents[0]!;
+ expect(agent.attributes[SENTRY_OP]?.value).toBe(GEN_AI_INVOKE_AGENT);
+ expect(agent.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('invoke_agent');
+ expect(agent.attributes[GEN_AI_AGENT_NAME]?.value).toBe('Hello');
+ expect(agent.parent_span_id).toBe(root.span_id);
+
+ const conversationId = agent.attributes[GEN_AI_CONVERSATION_ID]?.value;
+ expect(conversationId).toEqual(expect.any(String));
+
+ // Both turns, not just the first: they leave the provider by different paths (a tool
+ // call, then a final answer) and resolve their parent through separate tracker lookups.
+ for (const chat of chats) {
+ expect(chat.attributes[SENTRY_OP]?.value).toBe(GEN_AI_CHAT);
+ expect(chat.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.flue');
+ expect(chat.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('chat');
+ expect(chat.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(conversationId);
+ expect(chat.parent_span_id).toBe(agent.span_id);
+ expect(chat.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBeGreaterThan(0);
+ expect(chat.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBeGreaterThan(0);
+ expect(chat.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBeGreaterThan(0);
+ // Flue computes cost itself; no provider SDK reports it. The faux provider prices
+ // every model at zero, so this only proves the attribute is mapped.
+ expect(chat.attributes[GEN_AI_COST_TOTAL_TOKENS]?.value).toEqual(expect.any(Number));
+ }
+
+ // pi-ai's `toolUse` is reported as the conventions' `tool_call`.
+ expect(chats.map(chat => chat.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value).sort()).toEqual([
+ '["stop"]',
+ '["tool_call"]',
+ ]);
+
+ const tool = tools[0]!;
+ // The tool has to actually run: a schema mismatch still produces a correctly named and
+ // parented span, so only the status separates a real call from a rejected one.
+ expect(tool.status).toBe('ok');
+ expect(tool.attributes[SENTRY_OP]?.value).toBe(GEN_AI_EXECUTE_TOOL);
+ expect(tool.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.flue');
+ expect(tool.attributes[GEN_AI_TOOL_NAME]?.value).toBe('get_weather');
+
+ // Tool spans are siblings of `chat` under the agent invocation, matching how Flue's
+ // own OpenTelemetry adapter projects them.
+ expect(tool.parent_span_id).toBe(agent.span_id);
+ },
+ })
+ .start()
+ .completed();
+ });
+ });
+
+ createEsmTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => {
+ test('records the messages of the turns in the gen_ai conventions shape', async () => {
+ await createRunner()
+ .expect({
+ span: container => {
+ const chats = container.items
+ .filter(span => span.name === 'chat faux-model')
+ .sort((a, b) => a.start_timestamp - b.start_timestamp);
+ expect(chats).toHaveLength(2);
+
+ // Flue sends pi-ai's shape (`toolResult`, `toolCall`); Sentry renders the conventions.
+ expect(JSON.parse(String(chats[0]!.attributes[GEN_AI_INPUT_MESSAGES]?.value))).toEqual([
+ { role: 'user', parts: [{ type: 'text', content: 'What is the weather in Berlin?' }] },
+ ]);
+ expect(JSON.parse(String(chats[0]!.attributes[GEN_AI_OUTPUT_MESSAGES]?.value))).toEqual([
+ {
+ role: 'assistant',
+ parts: [{ type: 'tool_call', id: 'call_1', name: 'get_weather', arguments: '{"city":"Berlin"}' }],
+ finish_reason: 'tool_call',
+ },
+ ]);
+
+ const answerInput = JSON.parse(String(chats[1]!.attributes[GEN_AI_INPUT_MESSAGES]?.value));
+ expect(answerInput.map((message: { role: string }) => message.role)).toEqual(['user', 'assistant', 'tool']);
+ expect(answerInput[2].parts[0]).toMatchObject({
+ type: 'tool_call_response',
+ id: 'call_1',
+ name: 'get_weather',
+ });
+ expect(answerInput[2].parts[0].result).toContain('sunny in Berlin');
+ expect(chats[1]!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]?.value).toContain('You are a helpful assistant.');
+
+ // The tool span records the result the model receives, not Flue's internal wrapper.
+ const tool = container.items.find(span => span.name === 'execute_tool get_weather')!;
+ expect(tool.attributes[GEN_AI_TOOL_CALL_ARGUMENTS]?.value).toBe('{"city":"Berlin"}');
+ expect(tool.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toContain('sunny in Berlin');
+ expect(tool.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).not.toContain('details');
},
- });
-
- await createRunner()
- .unordered()
- .expect(expectAgentUnder('request-a'))
- .expect(expectAgentUnder('request-b'))
- .start()
- .completed();
+ })
+ .start()
+ .completed();
+ });
+ });
+
+ createEsmTests(__dirname, 'scenario-separate-requests.mjs', 'instrument.mjs', (createRunner, test) => {
+ 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);
+ },
});
- },
- FLUE_DEPENDENCIES,
- );
+
+ await createRunner()
+ .unordered()
+ .expect(expectAgentUnder('request-a'))
+ .expect(expectAgentUnder('request-b'))
+ .start()
+ .completed();
+ });
+ });
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-handler-exact-once/test.ts b/dev-packages/node-integration-tests/suites/tracing/mcp-handler-exact-once/test.ts
index 61c403f602ce..341d26feea75 100644
--- a/dev-packages/node-integration-tests/suites/tracing/mcp-handler-exact-once/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/mcp-handler-exact-once/test.ts
@@ -34,9 +34,6 @@ describe.each(['v1', 'v2'])('MCP TypeScript SDK %s', sdk => {
expect(root?.attributes['test.mcp.handlers_verified']).toEqual({ type: 'integer', value: 4 });
});
},
- {
- additionalDependencies: sdk === 'v1' ? { '@modelcontextprotocol/sdk': '1.30.0' } : undefined,
- copyPaths: ['run.cjs'],
- },
+ { copyPaths: ['run.cjs'] },
);
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server/manual-instrumentation/test.ts b/dev-packages/node-integration-tests/suites/tracing/mcp-server/manual-instrumentation/test.ts
index ecf2f6e2ef7c..be7a77eb0065 100644
--- a/dev-packages/node-integration-tests/suites/tracing/mcp-server/manual-instrumentation/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server/manual-instrumentation/test.ts
@@ -73,15 +73,9 @@ describe('MCP server spans (streamed, manual instrumentation)', () => {
});
});
- createEsmAndCjsTests(
- __dirname,
- 'scenario-v1.mjs',
- 'instrument.mjs',
- (createTestRunner, test) => {
- test('captures an MCP v1 initialize request queued before transport start once', async () => {
- await createTestRunner().unordered().expect({ span: assertInitializeSpan }).start().completed();
- });
- },
- { additionalDependencies: { '@modelcontextprotocol/sdk': '1.30.0' } },
- );
+ createEsmAndCjsTests(__dirname, 'scenario-v1.mjs', 'instrument.mjs', (createTestRunner, test) => {
+ test('captures an MCP v1 initialize request queued before transport start once', async () => {
+ await createTestRunner().unordered().expect({ span: assertInitializeSpan }).start().completed();
+ });
+ });
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server/v1/test.ts b/dev-packages/node-integration-tests/suites/tracing/mcp-server/v1/test.ts
index 178858873426..2d0e427ddb36 100644
--- a/dev-packages/node-integration-tests/suites/tracing/mcp-server/v1/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server/v1/test.ts
@@ -25,55 +25,49 @@ describe('MCP server spans (streamed, auto-instrumentation, v1)', () => {
cleanupChildProcesses();
});
- createEsmAndCjsTests(
- __dirname,
- 'scenario.mjs',
- 'instrument.mjs',
- (createTestRunner, test) => {
- test('auto-instruments the initialize handshake', async () => {
- await createTestRunner()
- .unordered()
- .expect({
- span: container => {
- const initialize = mcpSpan(container, 'initialize');
- expect(initialize.name).toBe('initialize');
- expect(initialize.attributes['sentry.origin']).toEqual({
- type: 'string',
- value: 'auto.function.mcp_server',
- });
- },
- })
- .start()
- .completed();
- });
+ createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createTestRunner, test) => {
+ test('auto-instruments the initialize handshake', async () => {
+ await createTestRunner()
+ .unordered()
+ .expect({
+ span: container => {
+ const initialize = mcpSpan(container, 'initialize');
+ expect(initialize.name).toBe('initialize');
+ expect(initialize.attributes['sentry.origin']).toEqual({
+ type: 'string',
+ value: 'auto.function.mcp_server',
+ });
+ },
+ })
+ .start()
+ .completed();
+ });
- test('auto-instruments a resource read', async () => {
- await createTestRunner()
- .unordered()
- .expect({
- span: container => {
- const resource = mcpSpan(container, 'resources/read');
- expect(resource.name).toBe('resources/read');
- expect(resource.attributes['mcp.resource.uri']?.value).toBe('echo://foobar');
- },
- })
- .start()
- .completed();
- });
+ test('auto-instruments a resource read', async () => {
+ await createTestRunner()
+ .unordered()
+ .expect({
+ span: container => {
+ const resource = mcpSpan(container, 'resources/read');
+ expect(resource.name).toBe('resources/read');
+ expect(resource.attributes['mcp.resource.uri']?.value).toBe('echo://foobar');
+ },
+ })
+ .start()
+ .completed();
+ });
- test('auto-instruments a tool call', async () => {
- await createTestRunner()
- .unordered()
- .expect({
- span: container => {
- const tool = mcpSpan(container, 'tools/call');
- expect(tool.name).toBe('tools/call echo');
- },
- })
- .start()
- .completed();
- });
- },
- { additionalDependencies: { '@modelcontextprotocol/sdk': '1.30.0' } },
- );
+ test('auto-instruments a tool call', async () => {
+ await createTestRunner()
+ .unordered()
+ .expect({
+ span: container => {
+ const tool = mcpSpan(container, 'tools/call');
+ expect(tool.name).toBe('tools/call echo');
+ },
+ })
+ .start()
+ .completed();
+ });
+ });
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/mistral-v2/test.ts b/dev-packages/node-integration-tests/suites/tracing/mistral-v2/test.ts
new file mode 100644
index 000000000000..5cd698bda1a2
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/mistral-v2/test.ts
@@ -0,0 +1,75 @@
+import {
+ GEN_AI_OPERATION_NAME,
+ GEN_AI_RESPONSE_ID,
+ GEN_AI_RESPONSE_STREAMING,
+ GEN_AI_USAGE_TOTAL_TOKENS,
+ SENTRY_ORIGIN,
+} from '@sentry/conventions/attributes';
+import { afterAll, describe, expect } from 'vitest';
+import { cleanupChildProcesses, createEsmTests } from '../../../utils/runner';
+
+const ORIGIN = 'auto.ai.mistralai';
+const MISTRAL_V2 = { additionalDependencies: { '@mistralai/mistralai': '2.6.4' } };
+
+// The `mistral` suite runs on the latest major; this keeps the still-supported v2 covered. The
+// scenarios are shared, since the request and response shapes we read are the same across majors.
+describe('Mistral integration (v2)', () => {
+ afterAll(() => {
+ cleanupChildProcesses();
+ });
+
+ createEsmTests(
+ __dirname,
+ '../mistral/scenario-chat.mjs',
+ '../mistral/instrument.mjs',
+ (createRunner, test) => {
+ test('creates chat and streamed chat spans on @mistralai/mistralai v2', async () => {
+ await createRunner()
+ .expect({
+ span: container => {
+ const chatSpan = container.items.find(
+ s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123',
+ );
+ expect(chatSpan).toBeDefined();
+ expect(chatSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('chat');
+ expect(chatSpan!.attributes[SENTRY_ORIGIN]?.value).toBe(ORIGIN);
+ expect(chatSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(25);
+
+ const streamSpan = container.items.find(
+ s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-123',
+ );
+ expect(streamSpan).toBeDefined();
+ expect(streamSpan!.attributes[GEN_AI_RESPONSE_STREAMING]?.value).toBe(true);
+ expect(streamSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(30);
+ },
+ })
+ .start()
+ .completed();
+ });
+ },
+ MISTRAL_V2,
+ );
+
+ createEsmTests(
+ __dirname,
+ '../mistral/scenario-embeddings.mjs',
+ '../mistral/instrument.mjs',
+ (createRunner, test) => {
+ test('creates embeddings spans on @mistralai/mistralai v2', async () => {
+ await createRunner()
+ .expect({
+ span: container => {
+ const embeddingsSpan = container.items.find(
+ s => s.attributes[GEN_AI_OPERATION_NAME]?.value === 'embeddings',
+ );
+ expect(embeddingsSpan).toBeDefined();
+ expect(embeddingsSpan!.attributes[SENTRY_ORIGIN]?.value).toBe(ORIGIN);
+ },
+ })
+ .start()
+ .completed();
+ });
+ },
+ MISTRAL_V2,
+ );
+});
diff --git a/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts b/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts
index 1b1e91310beb..8b5373b22921 100644
--- a/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts
@@ -30,7 +30,7 @@ const ORIGIN = 'auto.ai.mistralai';
// Not exported from `@sentry/conventions` yet; the SDK defines it in `ai/core/gen-ai-attributes`.
const GEN_AI_REQUEST_STREAM = 'gen_ai.request.stream';
-// ESM-only: `@mistralai/mistralai` v2 ships no CJS build, so CJS consumers load it via `require(esm)`,
+// ESM-only: `@mistralai/mistralai` v2+ ships no CJS build, so CJS consumers load it via `require(esm)`,
// whose auto-instrumentation is inconsistent across Node versions. The SDK's native mode is ESM, so we
// only run the suite there.
describe('Mistral integration', () => {
diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts
index 2a9cb3c3010a..a83b218ec2da 100644
--- a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts
@@ -1,5 +1,6 @@
import {
DB_CONNECTION_STRING,
+ DB_OPERATION_NAME,
DB_QUERY_SUMMARY,
DB_QUERY_TEXT,
DB_SYSTEM_NAME,
@@ -129,6 +130,7 @@ describe('mysql auto instrumentation', () => {
...COMMON_ATTRIBUTES,
[DB_QUERY_TEXT]: { type: 'string', value: queryText },
[DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' },
},
...COMMON_SPAN_PROPS,
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/neon/test.ts b/dev-packages/node-integration-tests/suites/tracing/neon/test.ts
index 485e6dbfac26..531885d20d3e 100644
--- a/dev-packages/node-integration-tests/suites/tracing/neon/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/neon/test.ts
@@ -2,6 +2,7 @@ import type { SerializedStreamedSpan } from '@sentry/core';
import {
DB_NAMESPACE,
DB_OPERATION_BATCH_SIZE,
+ DB_OPERATION_NAME,
DB_QUERY_SUMMARY,
DB_QUERY_TEXT,
DB_SYSTEM_NAME,
@@ -34,11 +35,13 @@ const CONNECTION_ATTRIBUTES = {
function expectedDbSpan({
name,
+ operation,
statement,
status = 'ok',
batchSize,
}: {
name: string;
+ operation: string;
statement: string;
status?: 'ok' | 'error';
batchSize?: number;
@@ -51,6 +54,7 @@ function expectedDbSpan({
[SENTRY_ORIGIN]: attr(ORIGIN),
[DB_QUERY_TEXT]: attr(statement),
[DB_QUERY_SUMMARY]: attr(name),
+ [DB_OPERATION_NAME]: attr(operation),
...(batchSize ? { [DB_OPERATION_BATCH_SIZE]: attr(batchSize, 'integer') } : {}),
}),
is_segment: false,
@@ -87,20 +91,30 @@ describeWithDockerCompose('neon auto instrumentation', { workingDirectory: [__di
const neonSpans = getNeonSpans(spans);
expect(neonSpans.every(span => span.parent_span_id === segment!.span_id)).toBe(true);
expect(neonSpans).toEqual([
- expectedDbSpan({ name: 'CREATE TABLE "User"', statement: CREATE_USER_TABLE_STATEMENT }),
- expectedDbSpan({ name: 'INSERT "User"', statement: 'INSERT INTO "User" ("email", "name") VALUES ($1, $2)' }),
- expectedDbSpan({ name: 'SELECT "User"', statement: 'SELECT * FROM "User" WHERE "name" = $1' }),
+ expectedDbSpan({ name: 'CREATE TABLE "User"', operation: 'CREATE', statement: CREATE_USER_TABLE_STATEMENT }),
+ expectedDbSpan({
+ name: 'INSERT "User"',
+ operation: 'INSERT',
+ statement: 'INSERT INTO "User" ("email", "name") VALUES ($1, $2)',
+ }),
+ expectedDbSpan({
+ name: 'SELECT "User"',
+ operation: 'SELECT',
+ statement: 'SELECT * FROM "User" WHERE "name" = $1',
+ }),
expectedDbSpan({
name: 'SELECT "User"; SELECT "User"',
+ operation: 'BATCH SELECT',
statement: 'SELECT "email" FROM "User"; SELECT "name" FROM "User"',
batchSize: 2,
}),
expectedDbSpan({
name: 'SELECT "does_not_exist_table"',
+ operation: 'SELECT',
statement: 'SELECT * FROM "does_not_exist_table"',
status: 'error',
}),
- expectedDbSpan({ name: 'DROP TABLE "User"', statement: 'DROP TABLE "User"' }),
+ expectedDbSpan({ name: 'DROP TABLE "User"', operation: 'DROP', statement: 'DROP TABLE "User"' }),
]);
});
});
@@ -121,15 +135,20 @@ describeWithDockerCompose('neon auto instrumentation', { workingDirectory: [__di
const neonSpans = getNeonSpans(spans);
expect(neonSpans.every(span => span.parent_span_id === segment!.span_id)).toBe(true);
expect(neonSpans).toEqual([
- expectedDbSpan({ name: 'CREATE TABLE "User"', statement: CREATE_USER_TABLE_STATEMENT }),
- expectedDbSpan({ name: 'INSERT "User"', statement: 'INSERT INTO "User" ("email", "name") VALUES ($1, $2)' }),
- expectedDbSpan({ name: 'SELECT "User"', statement: 'SELECT * FROM "User"' }),
+ expectedDbSpan({ name: 'CREATE TABLE "User"', operation: 'CREATE', statement: CREATE_USER_TABLE_STATEMENT }),
+ expectedDbSpan({
+ name: 'INSERT "User"',
+ operation: 'INSERT',
+ statement: 'INSERT INTO "User" ("email", "name") VALUES ($1, $2)',
+ }),
+ expectedDbSpan({ name: 'SELECT "User"', operation: 'SELECT', statement: 'SELECT * FROM "User"' }),
expectedDbSpan({
name: 'SELECT "does_not_exist_table"',
+ operation: 'SELECT',
statement: 'SELECT * FROM "does_not_exist_table"',
status: 'error',
}),
- expectedDbSpan({ name: 'DROP TABLE "User"', statement: 'DROP TABLE "User"' }),
+ expectedDbSpan({ name: 'DROP TABLE "User"', operation: 'DROP', statement: 'DROP TABLE "User"' }),
]);
});
});
@@ -154,10 +173,15 @@ describeWithDockerCompose('neon auto instrumentation', { workingDirectory: [__di
expect(neonSpans).toEqual([
expectedDbSpan({
name: 'SELECT pg_catalog.pg_user',
+ operation: 'SELECT',
statement: 'SELECT "email", "name" FROM pg_catalog.pg_user LIMIT ?',
status: 'error',
}),
- expectedDbSpan({ name: 'SELECT pg_catalog.pg_user', statement: 'SELECT "usename" FROM pg_catalog.pg_user' }),
+ expectedDbSpan({
+ name: 'SELECT pg_catalog.pg_user',
+ operation: 'SELECT',
+ statement: 'SELECT "usename" FROM pg_catalog.pg_user',
+ }),
]);
});
});
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 7bfd7947bb9f..f26aa34b63d7 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
@@ -23,18 +23,7 @@ import { GEN_AI_CHAT, GEN_AI_EXECUTE_TOOL, GEN_AI_INVOKE_AGENT } from '@sentry/c
import { afterAll, describe, expect } from 'vitest';
import { GEN_AI_TOOL_CALL_ID_ATTRIBUTE } from '../../../../../packages/server-utils/src/ai/core/gen-ai-attributes';
import { supports } from '../../../utils';
-import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
-
-// The pi packages declare `engines.node >= 22.19`, so they can't live in the package's root
-// `devDependencies` (that would break `yarn install` on the 20.19 CI matrix). Install them per-suite
-// instead, guarded by the `min: 22` skip below.
-const PI_DURABLE_DEPENDENCIES = {
- additionalDependencies: {
- '@earendil-works/pi-durable': '^1.2.0',
- '@earendil-works/pi-ai': '^1.2.0',
- '@earendil-works/chord': '^1.2.0',
- },
-};
+import { cleanupChildProcesses, createEsmTests } from '../../../utils/runner';
// 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}$/;
@@ -48,702 +37,613 @@ const CONTENT_ATTRIBUTES = [
GEN_AI_TOOL_CALL_RESULT,
];
+// The pi packages declare `engines.node >= 22.19`.
describe.runIf(supports({ min: 22 }))('pi-durable integration', () => {
afterAll(() => {
cleanupChildProcesses();
});
- createEsmAndCjsTests(
- __dirname,
- 'scenario.mjs',
- 'instrument.mjs',
- (createRunner, test, mode) => {
- // The pi packages are ESM-only: their `exports` maps have no `require` condition.
- if (mode === 'cjs') {
- return;
- }
-
- test('traces each run as its own invoke_agent trace with chat and execute_tool children', async () => {
- let toolRunTraceId: string | undefined;
- let brokenToolSpanId: string | undefined;
- let errorTraceId: string | undefined;
- let errorSpanId: string | undefined;
- const conversationIds: unknown[] = [];
-
- await createRunner()
- .unordered()
- .expect({
- span: container => {
- const spans = container.items;
- const agent = spans.find(span => span.is_segment)!;
- expect(agent.name).toBe('invoke_agent');
- expect(spans.filter(span => span.name === 'chat faux-model')).toHaveLength(2);
- // One span per run, request and tool call: a tool wrapped twice would add a span.
- expect(spans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.pi_durable')).toHaveLength(
- 5,
- );
-
- expect(agent.attributes[SENTRY_OP]?.value).toBe(GEN_AI_INVOKE_AGENT);
- 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;
- expect(conversationId).toMatch(UUID);
-
- const chats = spans
- .filter(span => span.name === 'chat faux-model')
- .sort((a, b) => a.start_timestamp - b.start_timestamp);
- for (const chat of chats) {
- expect(chat.parent_span_id).toBe(agent.span_id);
- expect(chat.attributes[SENTRY_OP]?.value).toBe(GEN_AI_CHAT);
- expect(chat.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.pi_durable');
- expect(chat.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe('faux');
- expect(chat.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('faux-model');
- expect(chat.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(conversationId);
- expect(chat.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBeGreaterThan(0);
- expect(chat.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBeGreaterThan(0);
- // pi-durable sends the prompt and the tools as positional system messages.
- expect(chat.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]?.value).toBe('You are a weather assistant.');
- expect(
- JSON.parse(String(chat.attributes[GEN_AI_TOOL_DEFINITIONS]?.value)).map(
- (tool: { name: string }) => tool.name,
- ),
- ).toEqual(['get_weather', 'broken']);
- }
-
- // Mapped from pi-ai's shape to the conventions: the tool calls and tool results the
- // second request carries are what Sentry's conversation view renders.
- expect(JSON.parse(String(chats[0]!.attributes[GEN_AI_INPUT_MESSAGES]?.value))).toEqual([
- { role: 'user', parts: [{ type: 'text', content: 'What is the weather in Berlin?' }] },
- ]);
- const answerInput = JSON.parse(String(chats[1]!.attributes[GEN_AI_INPUT_MESSAGES]?.value));
- expect(answerInput.map((message: { role: string }) => message.role)).toEqual([
- 'user',
- 'assistant',
- 'tool',
- 'tool',
- ]);
- expect(answerInput[1].parts).toEqual([
- { type: 'tool_call', id: 'call_1', name: 'get_weather', arguments: '{"city":"Berlin"}' },
- { type: 'tool_call', id: 'call_2', name: 'broken', arguments: '{}' },
- ]);
- expect(answerInput[2].parts).toEqual([
- {
- type: 'tool_call_response',
- id: 'call_1',
- name: 'get_weather',
- result: 'It is 21 degrees and sunny in Berlin.',
- },
- ]);
- expect(chats.map(chat => chat.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value).sort()).toEqual([
- '["stop"]',
- '["tool_call"]',
- ]);
- expect(chats.map(chat => chat.attributes[GEN_AI_OUTPUT_MESSAGES]?.value).join()).toContain(
- 'It is 21 degrees and sunny in Berlin.',
- );
-
- const weather = spans.find(span => span.name === 'execute_tool get_weather')!;
- expect(weather.parent_span_id).toBe(agent.span_id);
- expect(weather.status).toBe('ok');
- expect(weather.attributes[SENTRY_OP]?.value).toBe(GEN_AI_EXECUTE_TOOL);
- expect(weather.attributes[GEN_AI_TOOL_NAME]?.value).toBe('get_weather');
- expect(weather.attributes[GEN_AI_TOOL_DESCRIPTION]?.value).toBe('Get the current weather for a city.');
- expect(weather.attributes[GEN_AI_TOOL_CALL_ID_ATTRIBUTE]?.value).toBe('call_1');
- expect(weather.attributes[GEN_AI_TOOL_CALL_ARGUMENTS]?.value).toBe('{"city":"Berlin"}');
- expect(weather.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toContain('sunny in Berlin');
- expect(weather.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(conversationId);
-
- const broken = spans.find(span => span.name === 'execute_tool broken')!;
- expect(broken.parent_span_id).toBe(agent.span_id);
- expect(broken.status).toBe('error');
- // The error result pi-durable gives the model, not the thrown error.
- expect(broken.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toContain('[error] broken tool');
-
- toolRunTraceId = agent.trace_id;
- brokenToolSpanId = broken.span_id;
- conversationIds.push(conversationId);
- },
- })
- .expect({
- span: container => {
- const spans = container.items;
- const agent = spans.find(span => span.is_segment)!;
- expect(agent.name).toBe('invoke_agent');
- expect(spans).toHaveLength(2);
-
- const chat = spans.find(span => span.name === 'chat faux-model')!;
+ // The pi packages are ESM-only: their `exports` maps have no `require` condition.
+ createEsmTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
+ test('traces each run as its own invoke_agent trace with chat and execute_tool children', async () => {
+ let toolRunTraceId: string | undefined;
+ let brokenToolSpanId: string | undefined;
+ let errorTraceId: string | undefined;
+ let errorSpanId: string | undefined;
+ const conversationIds: unknown[] = [];
+
+ await createRunner()
+ .unordered()
+ .expect({
+ span: container => {
+ const spans = container.items;
+ const agent = spans.find(span => span.is_segment)!;
+ expect(agent.name).toBe('invoke_agent');
+ expect(spans.filter(span => span.name === 'chat faux-model')).toHaveLength(2);
+ // One span per run, request and tool call: a tool wrapped twice would add a span.
+ expect(spans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.pi_durable')).toHaveLength(
+ 5,
+ );
+
+ expect(agent.attributes[SENTRY_OP]?.value).toBe(GEN_AI_INVOKE_AGENT);
+ 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;
+ expect(conversationId).toMatch(UUID);
+
+ const chats = spans
+ .filter(span => span.name === 'chat faux-model')
+ .sort((a, b) => a.start_timestamp - b.start_timestamp);
+ for (const chat of chats) {
expect(chat.parent_span_id).toBe(agent.span_id);
- expect(chat.attributes[GEN_AI_OUTPUT_MESSAGES]?.value).toContain('Goodbye.');
-
- conversationIds.push(agent.attributes[GEN_AI_CONVERSATION_ID]?.value);
- },
- })
- .expect({
- span: container => {
- // The request that opened the Harness and submitted both inputs holds no agent work.
- expect(container.items).toHaveLength(1);
- expect(container.items[0]!.name).toBe('pi-durable-request');
- },
- })
- .expect({
- event: event => {
- const exception = event.exception?.values?.[0];
- expect(exception?.value).toBe('broken tool');
- expect(exception?.mechanism).toEqual({ type: 'auto.ai.pi_durable', handled: false });
-
- errorTraceId = event.contexts?.trace?.trace_id;
- errorSpanId = event.contexts?.trace?.span_id;
- },
- })
- .start()
- .completed();
-
- // Both runs belong to the root conversation, so they share one conversation id.
- expect(conversationIds).toHaveLength(2);
- expect(conversationIds[0]).toBe(conversationIds[1]);
- // The tool error is reported on the failing call's span, in the run's trace.
- expect(errorTraceId).toBe(toolRunTraceId);
- expect(errorSpanId).toBe(brokenToolSpanId);
- });
-
- test('records no message content when the client turns gen_ai recording off', async () => {
- await createRunner()
- .withEnv({ PI_DURABLE_RECORDING: 'off' })
- .unordered()
- .expect({
- span: container => {
- const spans = container.items;
- expect(spans).toHaveLength(5);
- for (const span of spans) {
- for (const attribute of CONTENT_ATTRIBUTES) {
- expect(span.attributes[attribute]).toBeUndefined();
- }
- }
- const weather = spans.find(span => span.name === 'execute_tool get_weather')!;
- expect(weather.attributes[GEN_AI_TOOL_DESCRIPTION]?.value).toBe('Get the current weather for a city.');
+ expect(chat.attributes[SENTRY_OP]?.value).toBe(GEN_AI_CHAT);
+ expect(chat.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.pi_durable');
+ expect(chat.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe('faux');
+ expect(chat.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('faux-model');
+ expect(chat.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(conversationId);
+ expect(chat.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBeGreaterThan(0);
+ expect(chat.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBeGreaterThan(0);
+ // pi-durable sends the prompt and the tools as positional system messages.
+ expect(chat.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]?.value).toBe('You are a weather assistant.');
expect(
- spans.find(span => span.name === 'chat faux-model')!.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value,
- ).toBeGreaterThan(0);
- },
- })
- .expect({
- span: container => {
- expect(container.items).toHaveLength(2);
- for (const span of container.items) {
- for (const attribute of CONTENT_ATTRIBUTES) {
- expect(span.attributes[attribute]).toBeUndefined();
- }
+ JSON.parse(String(chat.attributes[GEN_AI_TOOL_DEFINITIONS]?.value)).map(
+ (tool: { name: string }) => tool.name,
+ ),
+ ).toEqual(['get_weather', 'broken']);
+ }
+
+ // Mapped from pi-ai's shape to the conventions: the tool calls and tool results the
+ // second request carries are what Sentry's conversation view renders.
+ expect(JSON.parse(String(chats[0]!.attributes[GEN_AI_INPUT_MESSAGES]?.value))).toEqual([
+ { role: 'user', parts: [{ type: 'text', content: 'What is the weather in Berlin?' }] },
+ ]);
+ const answerInput = JSON.parse(String(chats[1]!.attributes[GEN_AI_INPUT_MESSAGES]?.value));
+ expect(answerInput.map((message: { role: string }) => message.role)).toEqual([
+ 'user',
+ 'assistant',
+ 'tool',
+ 'tool',
+ ]);
+ expect(answerInput[1].parts).toEqual([
+ { type: 'tool_call', id: 'call_1', name: 'get_weather', arguments: '{"city":"Berlin"}' },
+ { type: 'tool_call', id: 'call_2', name: 'broken', arguments: '{}' },
+ ]);
+ expect(answerInput[2].parts).toEqual([
+ {
+ type: 'tool_call_response',
+ id: 'call_1',
+ name: 'get_weather',
+ result: 'It is 21 degrees and sunny in Berlin.',
+ },
+ ]);
+ expect(chats.map(chat => chat.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value).sort()).toEqual([
+ '["stop"]',
+ '["tool_call"]',
+ ]);
+ expect(chats.map(chat => chat.attributes[GEN_AI_OUTPUT_MESSAGES]?.value).join()).toContain(
+ 'It is 21 degrees and sunny in Berlin.',
+ );
+
+ const weather = spans.find(span => span.name === 'execute_tool get_weather')!;
+ expect(weather.parent_span_id).toBe(agent.span_id);
+ expect(weather.status).toBe('ok');
+ expect(weather.attributes[SENTRY_OP]?.value).toBe(GEN_AI_EXECUTE_TOOL);
+ expect(weather.attributes[GEN_AI_TOOL_NAME]?.value).toBe('get_weather');
+ expect(weather.attributes[GEN_AI_TOOL_DESCRIPTION]?.value).toBe('Get the current weather for a city.');
+ expect(weather.attributes[GEN_AI_TOOL_CALL_ID_ATTRIBUTE]?.value).toBe('call_1');
+ expect(weather.attributes[GEN_AI_TOOL_CALL_ARGUMENTS]?.value).toBe('{"city":"Berlin"}');
+ expect(weather.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toContain('sunny in Berlin');
+ expect(weather.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(conversationId);
+
+ const broken = spans.find(span => span.name === 'execute_tool broken')!;
+ expect(broken.parent_span_id).toBe(agent.span_id);
+ expect(broken.status).toBe('error');
+ // The error result pi-durable gives the model, not the thrown error.
+ expect(broken.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toContain('[error] broken tool');
+
+ toolRunTraceId = agent.trace_id;
+ brokenToolSpanId = broken.span_id;
+ conversationIds.push(conversationId);
+ },
+ })
+ .expect({
+ span: container => {
+ const spans = container.items;
+ const agent = spans.find(span => span.is_segment)!;
+ expect(agent.name).toBe('invoke_agent');
+ expect(spans).toHaveLength(2);
+
+ const chat = spans.find(span => span.name === 'chat faux-model')!;
+ expect(chat.parent_span_id).toBe(agent.span_id);
+ expect(chat.attributes[GEN_AI_OUTPUT_MESSAGES]?.value).toContain('Goodbye.');
+
+ conversationIds.push(agent.attributes[GEN_AI_CONVERSATION_ID]?.value);
+ },
+ })
+ .expect({
+ span: container => {
+ // The request that opened the Harness and submitted both inputs holds no agent work.
+ expect(container.items).toHaveLength(1);
+ expect(container.items[0]!.name).toBe('pi-durable-request');
+ },
+ })
+ .expect({
+ event: event => {
+ const exception = event.exception?.values?.[0];
+ expect(exception?.value).toBe('broken tool');
+ expect(exception?.mechanism).toEqual({ type: 'auto.ai.pi_durable', handled: false });
+
+ errorTraceId = event.contexts?.trace?.trace_id;
+ errorSpanId = event.contexts?.trace?.span_id;
+ },
+ })
+ .start()
+ .completed();
+
+ // Both runs belong to the root conversation, so they share one conversation id.
+ expect(conversationIds).toHaveLength(2);
+ expect(conversationIds[0]).toBe(conversationIds[1]);
+ // The tool error is reported on the failing call's span, in the run's trace.
+ expect(errorTraceId).toBe(toolRunTraceId);
+ expect(errorSpanId).toBe(brokenToolSpanId);
+ });
+
+ test('records no message content when the client turns gen_ai recording off', async () => {
+ await createRunner()
+ .withEnv({ PI_DURABLE_RECORDING: 'off' })
+ .unordered()
+ .expect({
+ span: container => {
+ const spans = container.items;
+ expect(spans).toHaveLength(5);
+ for (const span of spans) {
+ for (const attribute of CONTENT_ATTRIBUTES) {
+ expect(span.attributes[attribute]).toBeUndefined();
}
- },
- })
- .expect({
- span: container => {
- expect(container.items).toHaveLength(1);
- expect(container.items[0]!.name).toBe('pi-durable-request');
- },
- })
- .expect({
- event: event => {
- expect(event.exception?.values?.[0]?.value).toBe('broken tool');
- },
- })
- .start()
- .completed();
- });
- },
- PI_DURABLE_DEPENDENCIES,
- );
-
- createEsmAndCjsTests(
- __dirname,
- 'scenario-tools.mjs',
- 'instrument.mjs',
- (createRunner, test, mode) => {
- if (mode === 'cjs') {
- return;
- }
-
- test('reports app tool failures but not failures of the built-in coding tools', async () => {
- const appErrors: unknown[] = [];
-
- // Ordered, with spans ignored: a captured failure of the built-in `bash` tool would be an
- // extra event before the sentinel, which the scenario sends once everything else is sent.
- // The two app failures are processed concurrently, so they may arrive in either order.
- await createRunner()
- .ignore('span')
- .expect({
- event: event => {
- expect(['app read failed', 'Intentional pi-durable tool failure']).toContain(
- event.exception?.values?.[0]?.value,
- );
- appErrors.push(event.exception?.values?.[0]?.value);
- },
- })
- .expect({
- event: event => {
- expect(['app read failed', 'Intentional pi-durable tool failure']).toContain(
- event.exception?.values?.[0]?.value,
- );
- appErrors.push(event.exception?.values?.[0]?.value);
- },
- })
- .expect({
- event: event => {
- expect(event.message).toBe('pi-durable tools done');
- },
- })
- .start()
- .completed();
-
- expect(appErrors.sort()).toEqual(['Intentional pi-durable tool failure', 'app read failed']);
- });
-
- test('records the tool result the model receives', async () => {
- let errorSpanId: string | undefined;
- let failingToolSpanId: string | undefined;
+ }
+ const weather = spans.find(span => span.name === 'execute_tool get_weather')!;
+ expect(weather.attributes[GEN_AI_TOOL_DESCRIPTION]?.value).toBe('Get the current weather for a city.');
+ expect(
+ spans.find(span => span.name === 'chat faux-model')!.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value,
+ ).toBeGreaterThan(0);
+ },
+ })
+ .expect({
+ span: container => {
+ expect(container.items).toHaveLength(2);
+ for (const span of container.items) {
+ for (const attribute of CONTENT_ATTRIBUTES) {
+ expect(span.attributes[attribute]).toBeUndefined();
+ }
+ }
+ },
+ })
+ .expect({
+ span: container => {
+ expect(container.items).toHaveLength(1);
+ expect(container.items[0]!.name).toBe('pi-durable-request');
+ },
+ })
+ .expect({
+ event: event => {
+ expect(event.exception?.values?.[0]?.value).toBe('broken tool');
+ },
+ })
+ .start()
+ .completed();
+ });
+ });
- await createRunner()
- .unordered()
- .expect({
- event: event => {
- const exception = event.exception?.values?.[0];
- expect(exception?.value).toBe('Intentional pi-durable tool failure');
- expect(exception?.mechanism).toEqual({ type: 'auto.ai.pi_durable', handled: false });
- errorSpanId = event.contexts?.trace?.span_id;
- },
- })
- .expect({
- event: event => {
- expect(event.exception?.values?.[0]?.value).toBe('app read failed');
- },
- })
- .expect({
- span: container => {
- const spans = container.items;
-
- const bash = spans.find(span => span.name === 'execute_tool bash')!;
- expect(bash.status).toBe('error');
- expect(bash.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toMatch(/Command exited with code \d+/);
-
- const read = spans.find(span => span.name === 'execute_tool read')!;
- expect(read.status).toBe('error');
- expect(read.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toContain('app read failed');
-
- // The streamed output becomes the result, since the tool returns no content.
- const streamer = spans.find(span => span.name === 'execute_tool streamer')!;
- expect(streamer.status).toBe('ok');
- expect(streamer.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toBe('line 1\nline 2\n');
-
- const secret = spans.find(span => span.name === 'execute_tool secret')!;
- expect(secret.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toBe('redacted by afterTool');
-
- const failNow = spans.find(span => span.name === 'execute_tool fail_now')!;
- expect(failNow.status).toBe('error');
- failingToolSpanId = failNow.span_id;
- },
- })
- .start()
- .completed();
+ createEsmTests(__dirname, 'scenario-tools.mjs', 'instrument.mjs', (createRunner, test) => {
+ test('reports app tool failures but not failures of the built-in coding tools', async () => {
+ const appErrors: unknown[] = [];
+
+ // Ordered, with spans ignored: a captured failure of the built-in `bash` tool would be an
+ // extra event before the sentinel, which the scenario sends once everything else is sent.
+ // The two app failures are processed concurrently, so they may arrive in either order.
+ await createRunner()
+ .ignore('span')
+ .expect({
+ event: event => {
+ expect(['app read failed', 'Intentional pi-durable tool failure']).toContain(
+ event.exception?.values?.[0]?.value,
+ );
+ appErrors.push(event.exception?.values?.[0]?.value);
+ },
+ })
+ .expect({
+ event: event => {
+ expect(['app read failed', 'Intentional pi-durable tool failure']).toContain(
+ event.exception?.values?.[0]?.value,
+ );
+ appErrors.push(event.exception?.values?.[0]?.value);
+ },
+ })
+ .expect({
+ event: event => {
+ expect(event.message).toBe('pi-durable tools done');
+ },
+ })
+ .start()
+ .completed();
+
+ expect(appErrors.sort()).toEqual(['Intentional pi-durable tool failure', 'app read failed']);
+ });
+
+ test('records the tool result the model receives', async () => {
+ let errorSpanId: string | undefined;
+ let failingToolSpanId: string | undefined;
+
+ await createRunner()
+ .unordered()
+ .expect({
+ event: event => {
+ const exception = event.exception?.values?.[0];
+ expect(exception?.value).toBe('Intentional pi-durable tool failure');
+ expect(exception?.mechanism).toEqual({ type: 'auto.ai.pi_durable', handled: false });
+ errorSpanId = event.contexts?.trace?.span_id;
+ },
+ })
+ .expect({
+ event: event => {
+ expect(event.exception?.values?.[0]?.value).toBe('app read failed');
+ },
+ })
+ .expect({
+ span: container => {
+ const spans = container.items;
+
+ const bash = spans.find(span => span.name === 'execute_tool bash')!;
+ expect(bash.status).toBe('error');
+ expect(bash.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toMatch(/Command exited with code \d+/);
+
+ const read = spans.find(span => span.name === 'execute_tool read')!;
+ expect(read.status).toBe('error');
+ expect(read.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toContain('app read failed');
+
+ // The streamed output becomes the result, since the tool returns no content.
+ const streamer = spans.find(span => span.name === 'execute_tool streamer')!;
+ expect(streamer.status).toBe('ok');
+ expect(streamer.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toBe('line 1\nline 2\n');
+
+ const secret = spans.find(span => span.name === 'execute_tool secret')!;
+ expect(secret.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toBe('redacted by afterTool');
+
+ const failNow = spans.find(span => span.name === 'execute_tool fail_now')!;
+ expect(failNow.status).toBe('error');
+ failingToolSpanId = failNow.span_id;
+ },
+ })
+ .start()
+ .completed();
+
+ expect(errorSpanId).toBe(failingToolSpanId);
+ });
+ });
- expect(errorSpanId).toBe(failingToolSpanId);
- });
- },
- PI_DURABLE_DEPENDENCIES,
- );
-
- createEsmAndCjsTests(
- __dirname,
- 'scenario-reports.mjs',
- 'instrument.mjs',
- (createRunner, test, mode) => {
- if (mode === 'cjs') {
- return;
- }
-
- test('captures failures pi-durable does not propagate, and tags idle compaction with its conversation', async () => {
- let runConversationId: unknown;
- let compactionConversationId: unknown;
+ createEsmTests(__dirname, 'scenario-reports.mjs', 'instrument.mjs', (createRunner, test) => {
+ test('captures failures pi-durable does not propagate, and tags idle compaction with its conversation', async () => {
+ let runConversationId: unknown;
+ let compactionConversationId: unknown;
+
+ await createRunner()
+ .unordered()
+ .expect({
+ event: event => {
+ const exception = event.exception?.values?.[0];
+ expect(exception?.value).toBe('afterResponse hook failed');
+ expect(exception?.mechanism).toEqual({ type: 'auto.ai.pi_durable', handled: false });
+ },
+ })
+ .expect({
+ event: event => {
+ const exception = event.exception?.values?.[0];
+ expect(exception?.value).toBe('card declined');
+ expect(exception?.mechanism).toEqual({ type: 'auto.ai.pi_durable', handled: false });
+ },
+ })
+ .expect({
+ span: container => {
+ const agent = container.items.find(span => span.is_segment)!;
+ expect(agent.name).toBe('invoke_agent');
+ runConversationId = agent.attributes[GEN_AI_CONVERSATION_ID]?.value;
+ },
+ })
+ .expect({
+ span: container => {
+ // A compaction started while idle belongs to no run, so its request is a trace of its own.
+ expect(container.items).toHaveLength(1);
+ const summary = container.items[0]!;
+ expect(summary.is_segment).toBe(true);
+ expect(summary.attributes[SENTRY_OP]?.value).toBe(GEN_AI_CHAT);
+ compactionConversationId = summary.attributes[GEN_AI_CONVERSATION_ID]?.value;
+ },
+ })
+ .start()
+ .completed();
+
+ expect(runConversationId).toMatch(UUID);
+ expect(compactionConversationId).toBe(runConversationId);
+ });
+ });
- await createRunner()
- .unordered()
- .expect({
- event: event => {
- const exception = event.exception?.values?.[0];
- expect(exception?.value).toBe('afterResponse hook failed');
- expect(exception?.mechanism).toEqual({ type: 'auto.ai.pi_durable', handled: false });
- },
- })
- .expect({
- event: event => {
- const exception = event.exception?.values?.[0];
- expect(exception?.value).toBe('card declined');
- expect(exception?.mechanism).toEqual({ type: 'auto.ai.pi_durable', handled: false });
- },
- })
- .expect({
- span: container => {
- const agent = container.items.find(span => span.is_segment)!;
- expect(agent.name).toBe('invoke_agent');
- runConversationId = agent.attributes[GEN_AI_CONVERSATION_ID]?.value;
- },
- })
- .expect({
- span: container => {
- // A compaction started while idle belongs to no run, so its request is a trace of its own.
- expect(container.items).toHaveLength(1);
- const summary = container.items[0]!;
- expect(summary.is_segment).toBe(true);
- expect(summary.attributes[SENTRY_OP]?.value).toBe(GEN_AI_CHAT);
- compactionConversationId = summary.attributes[GEN_AI_CONVERSATION_ID]?.value;
- },
- })
- .start()
- .completed();
+ createEsmTests(__dirname, 'scenario-subagent.mjs', 'instrument.mjs', (createRunner, test) => {
+ test('nests subagent runs under the tool call that started them', async () => {
+ await createRunner()
+ .unordered()
+ .expect({
+ span: container => {
+ const spans = container.items;
+ const parent = spans.find(span => span.is_segment)!;
+ const delegate = spans.find(span => span.name === 'execute_tool delegate')!;
+ expect(parent.name).toBe('invoke_agent');
+ 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.
+ 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);
+
+ const childChat = spans.find(span => span.parent_span_id === child.span_id)!;
+ expect(childChat.name).toBe('chat faux-model');
+ expect(childChat.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(childConversation);
+ expect(delegate.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toBe('Answer: Weather in Vienna?');
+ },
+ })
+ .expect({
+ span: container => {
+ const spans = container.items;
+ const parent = spans.find(span => span.is_segment)!;
+ const fork = spans.find(span => span.name === 'execute_tool fork_delegate')!;
+ const child = spans.find(span => span.name === 'invoke_agent' && !span.is_segment)!;
+ expect(fork.parent_span_id).toBe(parent.span_id);
+ expect(child.parent_span_id).toBe(fork.span_id);
+ expect(fork.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toBe('Answer: Forked task.');
+ },
+ })
+ .expect({
+ span: container => {
+ const spans = container.items;
+ const batch = spans.find(span => span.name === 'execute_tool batch')!;
+ const inner = spans.find(span => span.name === 'execute_tool inner')!;
+ expect(batch.parent_span_id).toBe(spans.find(span => span.is_segment)!.span_id);
+ // A tool that runs another tool itself: the inner call ends with its own result.
+ expect(inner.parent_span_id).toBe(batch.span_id);
+ expect(inner.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toBe('INNER RESULT');
+ expect(batch.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toBe('OUTER RESULT');
+ },
+ })
+ .start()
+ .completed();
+ });
+ });
- expect(runConversationId).toMatch(UUID);
- expect(compactionConversationId).toBe(runConversationId);
- });
- },
- PI_DURABLE_DEPENDENCIES,
- );
-
- createEsmAndCjsTests(
- __dirname,
- 'scenario-subagent.mjs',
- 'instrument.mjs',
- (createRunner, test, mode) => {
- if (mode === 'cjs') {
- return;
- }
-
- test('nests subagent runs under the tool call that started them', async () => {
- await createRunner()
- .unordered()
- .expect({
- span: container => {
- const spans = container.items;
- const parent = spans.find(span => span.is_segment)!;
- const delegate = spans.find(span => span.name === 'execute_tool delegate')!;
- expect(parent.name).toBe('invoke_agent');
- 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.
- 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);
-
- const childChat = spans.find(span => span.parent_span_id === child.span_id)!;
- expect(childChat.name).toBe('chat faux-model');
- expect(childChat.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(childConversation);
- expect(delegate.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toBe('Answer: Weather in Vienna?');
- },
- })
- .expect({
- span: container => {
- const spans = container.items;
- const parent = spans.find(span => span.is_segment)!;
- const fork = spans.find(span => span.name === 'execute_tool fork_delegate')!;
- const child = spans.find(span => span.name === 'invoke_agent' && !span.is_segment)!;
- expect(fork.parent_span_id).toBe(parent.span_id);
- expect(child.parent_span_id).toBe(fork.span_id);
- expect(fork.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toBe('Answer: Forked task.');
- },
- })
- .expect({
- span: container => {
- const spans = container.items;
- const batch = spans.find(span => span.name === 'execute_tool batch')!;
- const inner = spans.find(span => span.name === 'execute_tool inner')!;
- expect(batch.parent_span_id).toBe(spans.find(span => span.is_segment)!.span_id);
- // A tool that runs another tool itself: the inner call ends with its own result.
- expect(inner.parent_span_id).toBe(batch.span_id);
- expect(inner.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toBe('INNER RESULT');
- expect(batch.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toBe('OUTER RESULT');
- },
- })
- .start()
- .completed();
- });
- },
- PI_DURABLE_DEPENDENCIES,
- );
-
- createEsmAndCjsTests(
- __dirname,
- 'scenario-retention.mjs',
- 'instrument.mjs',
- (createRunner, test, mode) => {
- if (mode === 'cjs') {
- return;
- }
-
- // Bun does not implement `v8.setFlagsFromString`, which the scenario needs to force GC.
- test.runIf(supports({ runtimes: ['node', 'deno'] }))(
- 'does not keep the traces of tool calls alive for subagent conversations that never run',
- async () => {
- await createRunner()
- .ignore('span')
- .expect({ event: { message: 'pi-durable retention', extra: { toolCalls: 3, alive: 0 } } })
- .start()
- .completed();
- },
- );
- },
- PI_DURABLE_DEPENDENCIES,
- );
-
- createEsmAndCjsTests(
- __dirname,
- 'scenario-restart-abort.mjs',
- 'instrument.mjs',
- (createRunner, test, mode) => {
- if (mode === 'cjs') {
- return;
- }
-
- test('starts no run for an abort handler in a Harness that never saw the run', async () => {
- await createRunner()
- .ignore('span')
- .expect({
- event: {
- message: 'pi-durable restart abort',
- extra: { status: 'unanswered', reason: 'aborted', runsStarted: 0 },
- },
- })
- .start()
- .completed();
- });
- },
- PI_DURABLE_DEPENDENCIES,
- );
-
- createEsmAndCjsTests(
- __dirname,
- 'scenario-interrupted.mjs',
- 'instrument.mjs',
- (createRunner, test, mode) => {
- if (mode === 'cjs') {
- return;
- }
-
- test('reports a provider that throws as a fault, and nothing for failed, aborted or closed runs', async () => {
+ createEsmTests(__dirname, 'scenario-retention.mjs', 'instrument.mjs', (createRunner, test) => {
+ // Bun does not implement `v8.setFlagsFromString`, which the scenario needs to force GC.
+ test.runIf(supports({ runtimes: ['node', 'deno'] }))(
+ 'does not keep the traces of tool calls alive for subagent conversations that never run',
+ async () => {
await createRunner()
.ignore('span')
- .expect({
- event: event => {
- const exception = event.exception?.values?.[0];
- expect(exception?.value).toBe('provider broke');
- expect(exception?.mechanism).toEqual({ type: 'auto.ai.pi_durable', handled: false });
- },
- })
- .expect({
- event: event => {
- expect(event.message).toBe('pi-durable interrupted done');
- },
- })
+ .expect({ event: { message: 'pi-durable retention', extra: { toolCalls: 3, alive: 0 } } })
.start()
.completed();
- });
+ },
+ );
+ });
- test('ends every run that does not end with an answer', async () => {
- await createRunner()
- .unordered()
- .expect({
- span: container => {
- // 1. The model request failed: the run and the request are errors.
- const chat = container.items.find(span => span.name === 'chat faux-model')!;
- expect(chat.attributes[GEN_AI_INPUT_MESSAGES]?.value).toContain('Fail please.');
- expect(chat.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value).toBe('["error"]');
- expect(chat.status).toBe('error');
- expect(chat.attributes[SENTRY_STATUS_MESSAGE]?.value).toBe('internal_error');
- const agent = container.items.find(span => span.is_segment)!;
- expect(agent.name).toBe('invoke_agent');
- expect(agent.status).toBe('error');
- expect(agent.attributes[SENTRY_STATUS_MESSAGE]?.value).toBe('model_error');
- },
- })
- .expect({
- span: container => {
- // 2. The provider threw: the generation faulted.
- const chat = container.items.find(span => span.name === 'chat faux-model')!;
- expect(chat.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toBeUndefined();
- expect(chat.attributes[SENTRY_STATUS_MESSAGE]?.value).toBe('internal_error');
- const agent = container.items.find(span => span.is_segment)!;
- expect(agent.attributes[SENTRY_STATUS_MESSAGE]?.value).toBe('internal_error');
- },
- })
- .expect({
- span: container => {
- // 3. Aborted while the tool ran: cancelled, which streamed spans report as ok.
- const slow = container.items.find(span => span.name === 'execute_tool slow')!;
- expect(slow.attributes[GEN_AI_TOOL_CALL_ARGUMENTS]?.value).toBe('{"job":"abort"}');
- expect(slow.status).toBe('ok');
- expect(container.items.find(span => span.is_segment)!.status).toBe('ok');
- },
- })
- .expect({
- span: container => {
- // 4. Aborted while the model request ran.
- const chat = container.items.find(span => span.name === 'chat faux-model')!;
- expect(chat.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value).toBe('["aborted"]');
- expect(container.items).toHaveLength(2);
- },
- })
- .expect({
- span: container => {
- // 5. The Harness closed while the tool ran: the run still ends, so the trace has a segment.
- const slow = container.items.find(span => span.name === 'execute_tool slow')!;
- expect(slow.attributes[GEN_AI_TOOL_CALL_ARGUMENTS]?.value).toBe('{"job":"close"}');
- const agent = container.items.find(span => span.is_segment)!;
- expect(agent.name).toBe('invoke_agent');
- expect(slow.parent_span_id).toBe(agent.span_id);
- },
- })
- .expect({
- span: container => {
- // 6. The run failed, and the Harness closed right after `wait()`, before the commit that
- // ended the run resolved: the run keeps its own status.
- const chat = container.items.find(span => span.name === 'chat faux-model')!;
- expect(chat.attributes[GEN_AI_INPUT_MESSAGES]?.value).toContain('Fail, then close.');
- const agent = container.items.find(span => span.is_segment)!;
- expect(agent.status).toBe('error');
- expect(agent.attributes[SENTRY_STATUS_MESSAGE]?.value).toBe('model_error');
- },
- })
- .start()
- .completed();
- });
- },
- PI_DURABLE_DEPENDENCIES,
- );
-
- createEsmAndCjsTests(
- __dirname,
- 'scenario-concurrent.mjs',
- 'instrument.mjs',
- (createRunner, test, mode) => {
- if (mode === 'cjs') {
- return;
- }
-
- test('keeps concurrent runs apart and out of the scopes of the requests that submitted them', async () => {
- const runTraceIds = new Set();
- const errorTraceIds = new Set();
+ createEsmTests(__dirname, 'scenario-restart-abort.mjs', 'instrument.mjs', (createRunner, test) => {
+ test('starts no run for an abort handler in a Harness that never saw the run', async () => {
+ await createRunner()
+ .ignore('span')
+ .expect({
+ event: {
+ message: 'pi-durable restart abort',
+ extra: { status: 'unanswered', reason: 'aborted', runsStarted: 0 },
+ },
+ })
+ .start()
+ .completed();
+ });
+ });
- await createRunner()
- .unordered()
- .expect({
- span: container => {
- const spans = container.items;
- const agent = spans.find(span => span.is_segment)!;
- expect(agent.name).toBe('invoke_agent');
- const chats = spans.filter(span => span.name === 'chat faux-model');
- expect(JSON.parse(String(chats[0]!.attributes[GEN_AI_INPUT_MESSAGES]?.value))[0].parts[0].content).toBe(
- 'request A',
- );
- // 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(UUID);
- for (const span of spans) {
- expect(span.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(conversationId);
- expect(span.attributes['request']).toBeUndefined();
- }
- expect(
- spans.find(span => span.name === 'execute_tool work')!.attributes[GEN_AI_TOOL_CALL_ARGUMENTS]?.value,
- ).toBe('{"tag":"request A"}');
- runTraceIds.add(agent.trace_id);
- },
- })
- .expect({
- span: container => {
- const spans = container.items;
- const agent = spans.find(span => span.is_segment)!;
- expect(agent.name).toBe('invoke_agent');
- const chats = spans.filter(span => span.name === 'chat faux-model');
- expect(JSON.parse(String(chats[0]!.attributes[GEN_AI_INPUT_MESSAGES]?.value))[0].parts[0].content).toBe(
- 'request B',
- );
- const conversationId = agent.attributes[GEN_AI_CONVERSATION_ID]?.value;
- expect(conversationId).toMatch(UUID);
- for (const span of spans) {
- expect(span.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(conversationId);
- expect(span.attributes['request']).toBeUndefined();
- }
- expect(
- spans.find(span => span.name === 'execute_tool work')!.attributes[GEN_AI_TOOL_CALL_ARGUMENTS]?.value,
- ).toBe('{"tag":"request B"}');
- runTraceIds.add(agent.trace_id);
- },
- })
- .expect({
- event: event => {
- expect(event.exception?.values?.[0]?.value).toBe('work failed for request A');
- expect(event.tags?.request).toBeUndefined();
- errorTraceIds.add(String(event.contexts?.trace?.trace_id));
- },
- })
- .expect({
- event: event => {
- expect(event.exception?.values?.[0]?.value).toBe('work failed for request B');
- expect(event.tags?.request).toBeUndefined();
- errorTraceIds.add(String(event.contexts?.trace?.trace_id));
- },
- })
- .start()
- .completed();
+ createEsmTests(__dirname, 'scenario-interrupted.mjs', 'instrument.mjs', (createRunner, test) => {
+ test('reports a provider that throws as a fault, and nothing for failed, aborted or closed runs', async () => {
+ await createRunner()
+ .ignore('span')
+ .expect({
+ event: event => {
+ const exception = event.exception?.values?.[0];
+ expect(exception?.value).toBe('provider broke');
+ expect(exception?.mechanism).toEqual({ type: 'auto.ai.pi_durable', handled: false });
+ },
+ })
+ .expect({
+ event: event => {
+ expect(event.message).toBe('pi-durable interrupted done');
+ },
+ })
+ .start()
+ .completed();
+ });
+
+ test('ends every run that does not end with an answer', async () => {
+ await createRunner()
+ .unordered()
+ .expect({
+ span: container => {
+ // 1. The model request failed: the run and the request are errors.
+ const chat = container.items.find(span => span.name === 'chat faux-model')!;
+ expect(chat.attributes[GEN_AI_INPUT_MESSAGES]?.value).toContain('Fail please.');
+ expect(chat.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value).toBe('["error"]');
+ expect(chat.status).toBe('error');
+ expect(chat.attributes[SENTRY_STATUS_MESSAGE]?.value).toBe('internal_error');
+ const agent = container.items.find(span => span.is_segment)!;
+ expect(agent.name).toBe('invoke_agent');
+ expect(agent.status).toBe('error');
+ expect(agent.attributes[SENTRY_STATUS_MESSAGE]?.value).toBe('model_error');
+ },
+ })
+ .expect({
+ span: container => {
+ // 2. The provider threw: the generation faulted.
+ const chat = container.items.find(span => span.name === 'chat faux-model')!;
+ expect(chat.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toBeUndefined();
+ expect(chat.attributes[SENTRY_STATUS_MESSAGE]?.value).toBe('internal_error');
+ const agent = container.items.find(span => span.is_segment)!;
+ expect(agent.attributes[SENTRY_STATUS_MESSAGE]?.value).toBe('internal_error');
+ },
+ })
+ .expect({
+ span: container => {
+ // 3. Aborted while the tool ran: cancelled, which streamed spans report as ok.
+ const slow = container.items.find(span => span.name === 'execute_tool slow')!;
+ expect(slow.attributes[GEN_AI_TOOL_CALL_ARGUMENTS]?.value).toBe('{"job":"abort"}');
+ expect(slow.status).toBe('ok');
+ expect(container.items.find(span => span.is_segment)!.status).toBe('ok');
+ },
+ })
+ .expect({
+ span: container => {
+ // 4. Aborted while the model request ran.
+ const chat = container.items.find(span => span.name === 'chat faux-model')!;
+ expect(chat.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value).toBe('["aborted"]');
+ expect(container.items).toHaveLength(2);
+ },
+ })
+ .expect({
+ span: container => {
+ // 5. The Harness closed while the tool ran: the run still ends, so the trace has a segment.
+ const slow = container.items.find(span => span.name === 'execute_tool slow')!;
+ expect(slow.attributes[GEN_AI_TOOL_CALL_ARGUMENTS]?.value).toBe('{"job":"close"}');
+ const agent = container.items.find(span => span.is_segment)!;
+ expect(agent.name).toBe('invoke_agent');
+ expect(slow.parent_span_id).toBe(agent.span_id);
+ },
+ })
+ .expect({
+ span: container => {
+ // 6. The run failed, and the Harness closed right after `wait()`, before the commit that
+ // ended the run resolved: the run keeps its own status.
+ const chat = container.items.find(span => span.name === 'chat faux-model')!;
+ expect(chat.attributes[GEN_AI_INPUT_MESSAGES]?.value).toContain('Fail, then close.');
+ const agent = container.items.find(span => span.is_segment)!;
+ expect(agent.status).toBe('error');
+ expect(agent.attributes[SENTRY_STATUS_MESSAGE]?.value).toBe('model_error');
+ },
+ })
+ .start()
+ .completed();
+ });
+ });
- // Each tool error belongs to its run's trace.
- expect(errorTraceIds).toEqual(runTraceIds);
- });
- },
- PI_DURABLE_DEPENDENCIES,
- );
-
- createEsmAndCjsTests(
- __dirname,
- 'scenario-compaction.mjs',
- 'instrument.mjs',
- (createRunner, test, mode) => {
- if (mode === 'cjs') {
- return;
- }
-
- test('keeps a compaction that pi-durable starts for a run inside that run', async () => {
- await createRunner()
- .unordered()
- .expect({
- span: container => {
- const spans = container.items;
- const summary = spans.find(span =>
- String(span.attributes[GEN_AI_OUTPUT_MESSAGES]?.value).includes('Summary'),
- )!;
- const agent = spans.find(span => span.is_segment)!;
- expect(agent.name).toBe('invoke_agent');
- expect(summary.parent_span_id).toBe(agent.span_id);
- // pi-durable completes the summary without streaming; the run's own request streams.
- expect(summary.attributes[GEN_AI_RESPONSE_STREAMING]).toBeUndefined();
- const answer = spans.find(span =>
- String(span.attributes[GEN_AI_OUTPUT_MESSAGES]?.value).includes('Third answer'),
- )!;
- expect(answer.parent_span_id).toBe(agent.span_id);
- expect(answer.attributes[GEN_AI_RESPONSE_STREAMING]?.value).toBe(true);
- },
- })
- .expect({
- span: container => expect(container.items.find(span => span.is_segment)!.name).toBe('invoke_agent'),
- })
- .expect({
- span: container => expect(container.items.find(span => span.is_segment)!.name).toBe('invoke_agent'),
- })
- .start()
- .completed();
- });
- },
- PI_DURABLE_DEPENDENCIES,
- );
+ createEsmTests(__dirname, 'scenario-concurrent.mjs', 'instrument.mjs', (createRunner, test) => {
+ test('keeps concurrent runs apart and out of the scopes of the requests that submitted them', async () => {
+ const runTraceIds = new Set();
+ const errorTraceIds = new Set();
+
+ await createRunner()
+ .unordered()
+ .expect({
+ span: container => {
+ const spans = container.items;
+ const agent = spans.find(span => span.is_segment)!;
+ expect(agent.name).toBe('invoke_agent');
+ const chats = spans.filter(span => span.name === 'chat faux-model');
+ expect(JSON.parse(String(chats[0]!.attributes[GEN_AI_INPUT_MESSAGES]?.value))[0].parts[0].content).toBe(
+ 'request A',
+ );
+ // 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(UUID);
+ for (const span of spans) {
+ expect(span.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(conversationId);
+ expect(span.attributes['request']).toBeUndefined();
+ }
+ expect(
+ spans.find(span => span.name === 'execute_tool work')!.attributes[GEN_AI_TOOL_CALL_ARGUMENTS]?.value,
+ ).toBe('{"tag":"request A"}');
+ runTraceIds.add(agent.trace_id);
+ },
+ })
+ .expect({
+ span: container => {
+ const spans = container.items;
+ const agent = spans.find(span => span.is_segment)!;
+ expect(agent.name).toBe('invoke_agent');
+ const chats = spans.filter(span => span.name === 'chat faux-model');
+ expect(JSON.parse(String(chats[0]!.attributes[GEN_AI_INPUT_MESSAGES]?.value))[0].parts[0].content).toBe(
+ 'request B',
+ );
+ const conversationId = agent.attributes[GEN_AI_CONVERSATION_ID]?.value;
+ expect(conversationId).toMatch(UUID);
+ for (const span of spans) {
+ expect(span.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(conversationId);
+ expect(span.attributes['request']).toBeUndefined();
+ }
+ expect(
+ spans.find(span => span.name === 'execute_tool work')!.attributes[GEN_AI_TOOL_CALL_ARGUMENTS]?.value,
+ ).toBe('{"tag":"request B"}');
+ runTraceIds.add(agent.trace_id);
+ },
+ })
+ .expect({
+ event: event => {
+ expect(event.exception?.values?.[0]?.value).toBe('work failed for request A');
+ expect(event.tags?.request).toBeUndefined();
+ errorTraceIds.add(String(event.contexts?.trace?.trace_id));
+ },
+ })
+ .expect({
+ event: event => {
+ expect(event.exception?.values?.[0]?.value).toBe('work failed for request B');
+ expect(event.tags?.request).toBeUndefined();
+ errorTraceIds.add(String(event.contexts?.trace?.trace_id));
+ },
+ })
+ .start()
+ .completed();
+
+ // Each tool error belongs to its run's trace.
+ expect(errorTraceIds).toEqual(runTraceIds);
+ });
+ });
+
+ createEsmTests(__dirname, 'scenario-compaction.mjs', 'instrument.mjs', (createRunner, test) => {
+ test('keeps a compaction that pi-durable starts for a run inside that run', async () => {
+ await createRunner()
+ .unordered()
+ .expect({
+ span: container => {
+ const spans = container.items;
+ const summary = spans.find(span =>
+ String(span.attributes[GEN_AI_OUTPUT_MESSAGES]?.value).includes('Summary'),
+ )!;
+ const agent = spans.find(span => span.is_segment)!;
+ expect(agent.name).toBe('invoke_agent');
+ expect(summary.parent_span_id).toBe(agent.span_id);
+ // pi-durable completes the summary without streaming; the run's own request streams.
+ expect(summary.attributes[GEN_AI_RESPONSE_STREAMING]).toBeUndefined();
+ const answer = spans.find(span =>
+ String(span.attributes[GEN_AI_OUTPUT_MESSAGES]?.value).includes('Third answer'),
+ )!;
+ expect(answer.parent_span_id).toBe(agent.span_id);
+ expect(answer.attributes[GEN_AI_RESPONSE_STREAMING]?.value).toBe(true);
+ },
+ })
+ .expect({
+ span: container => expect(container.items.find(span => span.is_segment)!.name).toBe('invoke_agent'),
+ })
+ .expect({
+ span: container => expect(container.items.find(span => span.is_segment)!.name).toBe('invoke_agent'),
+ })
+ .start()
+ .completed();
+ });
+ });
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts b/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts
index 4dd383254d1f..ae2e256e33b1 100644
--- a/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts
@@ -1,6 +1,7 @@
import {
DB_CONNECTION_STRING,
DB_NAMESPACE,
+ DB_OPERATION_NAME,
DB_QUERY_SUMMARY,
DB_QUERY_TEXT,
DB_SYSTEM_NAME,
@@ -87,6 +88,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [
[SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN },
[SENTRY_OP]: { type: 'string', value: DB },
[DB_QUERY_SUMMARY]: { type: 'string', value: 'INSERT "User"' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'INSERT' },
},
name: 'INSERT "User"',
status: 'ok',
@@ -101,6 +103,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [
[SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN },
[SENTRY_OP]: { type: 'string', value: DB },
[DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT "User"' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' },
},
name: 'SELECT "User"',
status: 'ok',
@@ -114,6 +117,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [
[SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN },
[SENTRY_OP]: { type: 'string', value: DB },
[DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT "User"' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' },
}),
name: 'SELECT "User"',
status: 'ok',
@@ -126,6 +130,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [
[SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN },
[SENTRY_OP]: { type: 'string', value: DB },
[DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT "does_not_exist_table"' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' },
}),
name: 'SELECT "does_not_exist_table"',
status: 'error',
@@ -144,6 +149,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [
'CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(?) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id"))',
},
[DB_QUERY_SUMMARY]: { type: 'string', value: 'CREATE TABLE "User"' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'CREATE' },
},
},
{
@@ -156,6 +162,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [
[SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN },
[DB_QUERY_TEXT]: { type: 'string', value: 'DROP TABLE "User"' },
[DB_QUERY_SUMMARY]: { type: 'string', value: 'DROP TABLE "User"' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'DROP' },
},
},
]),
@@ -218,6 +225,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [
[SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN },
[SENTRY_OP]: { type: 'string', value: DB },
[DB_QUERY_SUMMARY]: { type: 'string', value: 'INSERT "User"' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'INSERT' },
},
name: 'INSERT "User"',
status: 'ok',
@@ -232,6 +240,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [
[SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN },
[SENTRY_OP]: { type: 'string', value: DB },
[DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT "User"' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' },
},
name: 'SELECT "User"',
status: 'ok',
@@ -250,6 +259,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [
'CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(?) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id"))',
},
[DB_QUERY_SUMMARY]: { type: 'string', value: 'CREATE TABLE "User"' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'CREATE' },
},
},
{
@@ -262,6 +272,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [
[SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN },
[DB_QUERY_TEXT]: { type: 'string', value: 'DROP TABLE "User"' },
[DB_QUERY_SUMMARY]: { type: 'string', value: 'DROP TABLE "User"' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'DROP' },
},
},
]),
@@ -300,6 +311,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [
[SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN },
[SENTRY_OP]: { type: 'string', value: DB },
[DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' },
}),
name: 'SELECT',
status: 'ok',
@@ -359,6 +371,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [
[SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN },
[SENTRY_OP]: { type: 'string', value: DB },
[DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' },
}),
name: 'SELECT',
status: 'ok',
@@ -397,6 +410,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [
[SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN },
[SENTRY_OP]: { type: 'string', value: DB },
[DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' },
}),
name: 'SELECT',
status: 'ok',
@@ -444,6 +458,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [
[SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN },
[SENTRY_OP]: { type: 'string', value: DB },
[DB_QUERY_SUMMARY]: { type: 'string', value: 'INSERT "NativeUser"' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'INSERT' },
[SERVER_ADDRESS]: { type: 'string', value: '127.0.0.1' },
[DB_CONNECTION_STRING]: {
type: 'string',
@@ -463,6 +478,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [
[SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN },
[SENTRY_OP]: { type: 'string', value: DB },
[DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT "NativeUser"' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' },
[SERVER_ADDRESS]: { type: 'string', value: '127.0.0.1' },
[DB_CONNECTION_STRING]: {
type: 'string',
@@ -490,6 +506,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [
'CREATE TABLE "NativeUser" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(?) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id"))',
},
[DB_QUERY_SUMMARY]: { type: 'string', value: 'CREATE TABLE "NativeUser"' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'CREATE' },
},
},
{
@@ -506,6 +523,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [
[SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN },
[DB_QUERY_TEXT]: { type: 'string', value: 'DROP TABLE "NativeUser"' },
[DB_QUERY_SUMMARY]: { type: 'string', value: 'DROP TABLE "NativeUser"' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'DROP' },
},
},
]),
@@ -568,6 +586,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [
[SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
[SENTRY_OP]: { type: 'string', value: DB },
[DB_QUERY_SUMMARY]: { type: 'string', value: 'INSERT "User"' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'INSERT' },
}),
name: 'INSERT "User"',
status: 'ok',
@@ -581,6 +600,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [
[SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
[SENTRY_OP]: { type: 'string', value: DB },
[DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT "User"' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' },
}),
name: 'SELECT "User"',
status: 'ok',
@@ -592,6 +612,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [
[SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
[SENTRY_OP]: { type: 'string', value: DB },
[DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT "does_not_exist_table"' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' },
}),
name: 'SELECT "does_not_exist_table"',
status: 'error',
@@ -629,6 +650,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [
[SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
[SENTRY_OP]: { type: 'string', value: DB },
[DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' },
}),
name: 'SELECT',
status: 'ok',
@@ -700,6 +722,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [
[SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
[SENTRY_OP]: { type: 'string', value: DB },
[DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' },
}),
name: 'SELECT',
status: 'ok',
@@ -747,6 +770,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [
[SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
[SENTRY_OP]: { type: 'string', value: DB },
[DB_QUERY_SUMMARY]: { type: 'string', value: 'INSERT "User"' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'INSERT' },
}),
name: 'INSERT "User"',
status: 'ok',
@@ -759,6 +783,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [
[SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
[SENTRY_OP]: { type: 'string', value: DB },
[DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT "User"' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' },
}),
name: 'SELECT "User"',
status: 'ok',
diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs-tracing-channel/docker-compose.yml b/dev-packages/node-integration-tests/suites/tracing/postgresjs-tracing-channel/docker-compose.yml
new file mode 100644
index 000000000000..df5154fc8f27
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs-tracing-channel/docker-compose.yml
@@ -0,0 +1,16 @@
+services:
+ db:
+ image: postgres:13
+ restart: always
+ ports:
+ - '5445:5432'
+ environment:
+ POSTGRES_USER: test
+ POSTGRES_PASSWORD: test
+ POSTGRES_DB: test_db
+ healthcheck:
+ test: ['CMD-SHELL', 'pg_isready -U test -d test_db']
+ interval: 2s
+ timeout: 3s
+ retries: 30
+ start_period: 5s
diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs-tracing-channel/instrument-requestHook.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs-tracing-channel/instrument-requestHook.mjs
new file mode 100644
index 000000000000..746f07de61f6
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs-tracing-channel/instrument-requestHook.mjs
@@ -0,0 +1,21 @@
+import * as Sentry from '@sentry/node';
+import { loggingTransport } from '@sentry-internal/node-integration-tests';
+
+const requestHook = (span, sanitizedSqlQuery, connectionContext) => {
+ span.setAttributes({
+ 'custom.requestHook': 'called',
+ 'custom.requestHook.query': sanitizedSqlQuery,
+ 'custom.requestHook.database': connectionContext?.ATTR_DB_NAMESPACE,
+ 'custom.requestHook.host': connectionContext?.ATTR_SERVER_ADDRESS,
+ 'custom.requestHook.port': connectionContext?.ATTR_SERVER_PORT,
+ });
+};
+
+// `postgresJsIntegration()` forwards the `requestHook` to the `postgres:query` channel subscriber.
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ release: '1.0',
+ tracesSampleRate: 1.0,
+ transport: loggingTransport,
+ integrations: [Sentry.postgresJsIntegration({ requestHook })],
+});
diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs-tracing-channel/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs-tracing-channel/instrument.mjs
new file mode 100644
index 000000000000..46a27dd03b74
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs-tracing-channel/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@dsn.ingest.sentry.io/1337',
+ release: '1.0',
+ tracesSampleRate: 1.0,
+ transport: loggingTransport,
+});
diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs-tracing-channel/scenario-unsafe.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs-tracing-channel/scenario-unsafe.mjs
new file mode 100644
index 000000000000..deafe3408630
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs-tracing-channel/scenario-unsafe.mjs
@@ -0,0 +1,37 @@
+import * as Sentry from '@sentry/node';
+import { uuid4 } from '@sentry/core';
+import postgres from 'postgres';
+import { waitForConnection } from '@sentry-internal/node-integration-tests';
+
+// Test with plain object options
+const sql = postgres({ port: 5445, user: 'test', password: 'test', database: 'test_db' });
+
+async function run() {
+ await Sentry.startSpan(
+ {
+ name: 'Test Transaction',
+ op: 'transaction',
+ },
+ async () => {
+ try {
+ await waitForConnection(() => sql`SELECT 1`);
+ // Test sql.unsafe() - this was not being instrumented before the fix
+ await sql.unsafe('CREATE TABLE "User" ("id" SERIAL NOT NULL, "email" TEXT NOT NULL, PRIMARY KEY ("id"))');
+
+ const email = `${uuid4()}@domain.com`;
+ await sql.unsafe('INSERT INTO "User" ("email") VALUES ($1)', [email]);
+
+ await sql.unsafe('SELECT * FROM "User" WHERE "email" = $1', [email]);
+
+ await sql.unsafe('DROP TABLE "User"');
+
+ // This will be captured as an error as the table no longer exists
+ await sql.unsafe('SELECT * FROM "User"');
+ } finally {
+ await sql.end();
+ }
+ },
+ );
+}
+
+run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs-tracing-channel/scenario-url.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs-tracing-channel/scenario-url.mjs
new file mode 100644
index 000000000000..259d2c7db90e
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs-tracing-channel/scenario-url.mjs
@@ -0,0 +1,44 @@
+import * as Sentry from '@sentry/node';
+import { uuid4 } from '@sentry/core';
+import postgres from 'postgres';
+import { waitForConnection } from '@sentry-internal/node-integration-tests';
+
+// Test URL-based initialization - this is the common pattern that was causing the regression
+const sql = postgres('postgres://test:test@localhost:5445/test_db');
+
+async function run() {
+ await Sentry.startSpan(
+ {
+ name: 'Test Transaction',
+ op: 'transaction',
+ },
+ async () => {
+ try {
+ await waitForConnection(() => sql`SELECT 1`);
+ await sql`
+ CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id"));
+ `;
+
+ const email = `${uuid4()}@domain.com`;
+ await sql`
+ INSERT INTO "User" ("email", "name") VALUES (${email}, 'tim');
+ `;
+
+ await sql`
+ SELECT * FROM "User" WHERE "email" = ${email};
+ `;
+
+ await sql`
+ DELETE FROM "User" WHERE "email" = ${email};
+ `;
+ } finally {
+ await sql`
+ DROP TABLE "User";
+ `;
+ await sql.end();
+ }
+ },
+ );
+}
+
+run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs-tracing-channel/scenario-wrapper.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs-tracing-channel/scenario-wrapper.mjs
new file mode 100644
index 000000000000..992d92979f35
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs-tracing-channel/scenario-wrapper.mjs
@@ -0,0 +1,28 @@
+import * as Sentry from '@sentry/node';
+import postgres from 'postgres';
+import { waitForConnection } from '@sentry-internal/node-integration-tests';
+
+const sql = Sentry.instrumentPostgresJsSql(
+ postgres({ port: 5445, user: 'test', password: 'test', database: 'test_db' }),
+);
+
+async function run() {
+ await waitForConnection(() => sql`SELECT 1`);
+
+ await Sentry.startSpan(
+ {
+ name: 'Test Transaction',
+ op: 'transaction',
+ },
+ async () => {
+ try {
+ await sql`SELECT 1 AS one`;
+ await sql.unsafe('SELECT 2 AS two');
+ } finally {
+ await sql.end();
+ }
+ },
+ );
+}
+
+run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs-tracing-channel/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs-tracing-channel/scenario.mjs
new file mode 100644
index 000000000000..3be2e0d51998
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs-tracing-channel/scenario.mjs
@@ -0,0 +1,74 @@
+import * as Sentry from '@sentry/node';
+import { uuid4 } from '@sentry/core';
+import postgres from 'postgres';
+import { waitForConnection } from '@sentry-internal/node-integration-tests';
+
+const sql = postgres({ port: 5445, user: 'test', password: 'test', database: 'test_db' });
+
+async function run() {
+ await Sentry.startSpan(
+ {
+ name: 'Test Transaction',
+ op: 'transaction',
+ },
+ async () => {
+ try {
+ await waitForConnection(() => sql`SELECT 1`);
+ await sql`
+ CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id"));
+ `;
+
+ const email = `${uuid4()}@domain.com`;
+ await sql`
+ INSERT INTO "User" ("email", "name") VALUES (${email}, 'tim');
+ `;
+
+ await sql`
+ UPDATE "User" SET "name" = 'Foo' WHERE "email" = ${email};
+ `;
+
+ await sql`
+ SELECT * FROM "User" WHERE "email" = ${email};
+ `;
+
+ // Test parameterized queries
+ await sql`
+ SELECT * FROM "User" WHERE "email" = ${email} AND "name" = ${'Foo'};
+ `;
+
+ // Test DELETE operation
+ await sql`
+ DELETE FROM "User" WHERE "email" = ${email};
+ `;
+
+ // Test INSERT with RETURNING
+ await sql`
+ INSERT INTO "User" ("email", "name") VALUES (${email}, 'Test User') RETURNING *;
+ `;
+
+ // Test cursor-based queries
+ await sql`SELECT * from generate_series(1,1000) as x `.cursor(10, async rows => {
+ await Promise.all(rows);
+ });
+
+ // Test multiple rows at once
+ await sql`
+ SELECT * FROM "User" LIMIT 10;
+ `;
+
+ await sql`
+ DROP TABLE "User";
+ `;
+
+ // This will be captured as an error as the table no longer exists
+ await sql`
+ SELECT * FROM "User" WHERE "email" = ${email};
+ `;
+ } finally {
+ await sql.end();
+ }
+ },
+ );
+}
+
+run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs-tracing-channel/test.ts b/dev-packages/node-integration-tests/suites/tracing/postgresjs-tracing-channel/test.ts
new file mode 100644
index 000000000000..13f97d03d277
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs-tracing-channel/test.ts
@@ -0,0 +1,651 @@
+import {
+ DB_NAMESPACE,
+ DB_OPERATION_NAME,
+ DB_QUERY_SUMMARY,
+ DB_QUERY_TEXT,
+ DB_RESPONSE_STATUS_CODE,
+ DB_SYSTEM_NAME,
+ ERROR_TYPE,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
+ SENTRY_STATUS_MESSAGE,
+ SERVER_ADDRESS,
+ SERVER_PORT,
+} from '@sentry/conventions/attributes';
+import { DB } from '@sentry/conventions/op';
+import type { Event, SerializedStreamedSpanContainer } from '@sentry/core';
+import { afterAll, describe, expect } from 'vitest';
+import { cleanupChildProcesses, createEsmTests, describeWithDockerCompose } from '../../../utils/runner';
+
+// postgres >= 4 publishes `postgres:query` on `node:diagnostics_channel` itself, so these spans come from
+// `subscribePostgresJsDiagnosticChannels`, not the orchestrion transform (gated to `< 4`). v4 is ESM-only.
+const OPTIONS = { additionalDependencies: { postgres: '^4.0.0' } };
+
+describeWithDockerCompose('postgresjs v4 tracing channel', { workingDirectory: [__dirname] }, () => {
+ afterAll(() => {
+ cleanupChildProcesses();
+ });
+
+ const ORIGIN = 'auto.db.postgresjs';
+
+ describe('basic', () => {
+ const EXPECTED_SPANS = {
+ items: expect.arrayContaining([
+ expect.objectContaining({ name: 'Test Transaction', is_segment: true }),
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ [DB_NAMESPACE]: { type: 'string', value: 'test_db' },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'postgres' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'CREATE TABLE' },
+ [DB_QUERY_TEXT]: {
+ type: 'string',
+ value:
+ 'CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(?) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id"))',
+ },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 5445 },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'CREATE TABLE "User"' },
+ }),
+ name: 'CREATE TABLE "User"',
+ status: 'ok',
+ parent_span_id: expect.any(String),
+ span_id: expect.any(String),
+ start_timestamp: expect.any(Number),
+ end_timestamp: expect.any(Number),
+ trace_id: expect.any(String),
+ is_segment: false,
+ }),
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ [DB_NAMESPACE]: { type: 'string', value: 'test_db' },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'postgres' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'INSERT' },
+ [DB_QUERY_TEXT]: { type: 'string', value: 'INSERT INTO "User" ("email", "name") VALUES ($1, ?)' },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 5445 },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'INSERT "User"' },
+ }),
+ name: 'INSERT "User"',
+ status: 'ok',
+ parent_span_id: expect.any(String),
+ span_id: expect.any(String),
+ start_timestamp: expect.any(Number),
+ end_timestamp: expect.any(Number),
+ trace_id: expect.any(String),
+ is_segment: false,
+ }),
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ [DB_NAMESPACE]: { type: 'string', value: 'test_db' },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'postgres' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'UPDATE' },
+ [DB_QUERY_TEXT]: { type: 'string', value: 'UPDATE "User" SET "name" = ? WHERE "email" = $1' },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 5445 },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'UPDATE "User"' },
+ }),
+ name: 'UPDATE "User"',
+ status: 'ok',
+ parent_span_id: expect.any(String),
+ span_id: expect.any(String),
+ start_timestamp: expect.any(Number),
+ end_timestamp: expect.any(Number),
+ trace_id: expect.any(String),
+ is_segment: false,
+ }),
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ [DB_NAMESPACE]: { type: 'string', value: 'test_db' },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'postgres' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' },
+ [DB_QUERY_TEXT]: { type: 'string', value: 'SELECT * FROM "User" WHERE "email" = $1' },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 5445 },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT "User"' },
+ }),
+ name: 'SELECT "User"',
+ status: 'ok',
+ parent_span_id: expect.any(String),
+ span_id: expect.any(String),
+ start_timestamp: expect.any(Number),
+ end_timestamp: expect.any(Number),
+ trace_id: expect.any(String),
+ is_segment: false,
+ }),
+ // Parameterized query test - verifies that tagged template queries with interpolations
+ // are properly reconstructed with $1, $2 placeholders which are PRESERVED per OTEL spec
+ // (PostgreSQL $n placeholders indicate parameterized queries that don't leak sensitive data)
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ [DB_NAMESPACE]: { type: 'string', value: 'test_db' },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'postgres' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' },
+ [DB_QUERY_TEXT]: { type: 'string', value: 'SELECT * FROM "User" WHERE "email" = $1 AND "name" = $2' },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 5445 },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT "User"' },
+ }),
+ name: 'SELECT "User"',
+ status: 'ok',
+ parent_span_id: expect.any(String),
+ span_id: expect.any(String),
+ start_timestamp: expect.any(Number),
+ end_timestamp: expect.any(Number),
+ trace_id: expect.any(String),
+ is_segment: false,
+ }),
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ [DB_NAMESPACE]: { type: 'string', value: 'test_db' },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'postgres' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' },
+ [DB_QUERY_TEXT]: { type: 'string', value: 'SELECT * from generate_series(?,?) as x' },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 5445 },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT generate_series' },
+ }),
+ name: 'SELECT generate_series',
+ status: 'ok',
+ parent_span_id: expect.any(String),
+ span_id: expect.any(String),
+ start_timestamp: expect.any(Number),
+ end_timestamp: expect.any(Number),
+ trace_id: expect.any(String),
+ is_segment: false,
+ }),
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ [DB_NAMESPACE]: { type: 'string', value: 'test_db' },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'postgres' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'DROP TABLE' },
+ [DB_QUERY_TEXT]: { type: 'string', value: 'DROP TABLE "User"' },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 5445 },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'DROP TABLE "User"' },
+ }),
+ name: 'DROP TABLE "User"',
+ status: 'ok',
+ parent_span_id: expect.any(String),
+ span_id: expect.any(String),
+ start_timestamp: expect.any(Number),
+ end_timestamp: expect.any(Number),
+ trace_id: expect.any(String),
+ is_segment: false,
+ }),
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ [DB_NAMESPACE]: { type: 'string', value: 'test_db' },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'postgres' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' },
+ [DB_RESPONSE_STATUS_CODE]: { type: 'string', value: '42P01' },
+ [ERROR_TYPE]: { type: 'string', value: 'PostgresError' },
+ [DB_QUERY_TEXT]: { type: 'string', value: 'SELECT * FROM "User" WHERE "email" = $1' },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 5445 },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT "User"' },
+ [SENTRY_STATUS_MESSAGE]: { type: 'string', value: 'relation "User" does not exist' },
+ }),
+ name: 'SELECT "User"',
+ status: 'error',
+ parent_span_id: expect.any(String),
+ span_id: expect.any(String),
+ start_timestamp: expect.any(Number),
+ end_timestamp: expect.any(Number),
+ trace_id: expect.any(String),
+ is_segment: false,
+ }),
+ expect.objectContaining({
+ name: 'DELETE "User"',
+ is_segment: false,
+ status: 'ok',
+ attributes: expect.objectContaining({
+ [DB_NAMESPACE]: { type: 'string', value: 'test_db' },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'postgres' },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 5445 },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'DELETE' },
+ [DB_QUERY_TEXT]: { type: 'string', value: 'DELETE FROM "User" WHERE "email" = $1' },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'DELETE "User"' },
+ }),
+ }),
+ expect.objectContaining({
+ name: 'INSERT "User"',
+ is_segment: false,
+ status: 'ok',
+ attributes: expect.objectContaining({
+ [DB_NAMESPACE]: { type: 'string', value: 'test_db' },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'postgres' },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 5445 },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'INSERT' },
+ [DB_QUERY_TEXT]: {
+ type: 'string',
+ value: 'INSERT INTO "User" ("email", "name") VALUES ($1, ?) RETURNING *',
+ },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'INSERT "User"' },
+ }),
+ }),
+ ]),
+ };
+
+ const EXPECTED_ERROR_EVENT = {
+ event_id: expect.any(String),
+ contexts: {
+ trace: {
+ trace_id: expect.any(String),
+ span_id: expect.any(String),
+ },
+ },
+ exception: {
+ values: [
+ {
+ type: 'PostgresError',
+ value: 'relation "User" does not exist',
+ stacktrace: {
+ frames: expect.arrayContaining([
+ // v4 rewrites the error stack to point at the caller rather than its own connection code.
+ expect.objectContaining({
+ module: 'scenario',
+ filename: expect.stringMatching(/scenario\.mjs$/),
+ lineno: expect.any(Number),
+ colno: expect.any(Number),
+ }),
+ ]),
+ },
+ },
+ ],
+ },
+ };
+
+ createEsmTests(
+ __dirname,
+ 'scenario.mjs',
+ 'instrument.mjs',
+ (createTestRunner, test) => {
+ test('should auto-instrument `postgres` package', { timeout: 60_000 }, async () => {
+ let receivedSpans: SerializedStreamedSpanContainer['items'] = [];
+ let errorEvent: Event | undefined;
+ await createTestRunner()
+ .expect({
+ span: container => {
+ expect(container.items.find(span => span.is_segment)?.name).toBe('Test Transaction');
+ receivedSpans = container.items;
+ },
+ })
+ .expect({
+ event: event => {
+ errorEvent = event;
+ },
+ })
+ // The error event is captured via an unhandled rejection processed on a later tick than
+ // the spans, so the two envelopes can reach the transport in either order.
+ .unordered()
+ .start()
+ .completed();
+ expect({ items: receivedSpans }).toMatchObject(EXPECTED_SPANS);
+ for (const span of receivedSpans.filter(span => span.attributes[SENTRY_OP]?.value === DB)) {
+ expect(span.attributes[DB_QUERY_SUMMARY]).toEqual({ type: 'string', value: span.name });
+ }
+ expect(errorEvent).toMatchObject(EXPECTED_ERROR_EVENT);
+
+ const segmentSpanId = receivedSpans.find(span => span.is_segment)?.span_id;
+ for (const span of receivedSpans.filter(span => span.attributes[SENTRY_OP]?.value === DB)) {
+ expect(span.parent_span_id).toBe(segmentSpanId);
+ }
+ });
+ },
+ OPTIONS,
+ );
+ });
+
+ describe('requestHook', () => {
+ const EXPECTED_SPANS = {
+ items: expect.arrayContaining([
+ expect.objectContaining({ name: 'Test Transaction', is_segment: true }),
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ [DB_NAMESPACE]: { type: 'string', value: 'test_db' },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'postgres' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'CREATE TABLE' },
+ [DB_QUERY_TEXT]: {
+ type: 'string',
+ value:
+ 'CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(?) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id"))',
+ },
+ 'custom.requestHook': { type: 'string', value: 'called' },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 5445 },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'CREATE TABLE "User"' },
+ 'custom.requestHook.query': {
+ type: 'string',
+ value:
+ 'CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(?) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id"))',
+ },
+ 'custom.requestHook.database': { type: 'string', value: 'test_db' },
+ }),
+ name: 'CREATE TABLE "User"',
+ status: 'ok',
+ is_segment: false,
+ }),
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ [DB_NAMESPACE]: { type: 'string', value: 'test_db' },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'postgres' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'INSERT' },
+ [DB_QUERY_TEXT]: { type: 'string', value: 'INSERT INTO "User" ("email", "name") VALUES ($1, ?)' },
+ 'custom.requestHook': { type: 'string', value: 'called' },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 5445 },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'INSERT "User"' },
+ 'custom.requestHook.query': {
+ type: 'string',
+ value: 'INSERT INTO "User" ("email", "name") VALUES ($1, ?)',
+ },
+ 'custom.requestHook.database': { type: 'string', value: 'test_db' },
+ }),
+ name: 'INSERT "User"',
+ status: 'ok',
+ is_segment: false,
+ }),
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ [DB_NAMESPACE]: { type: 'string', value: 'test_db' },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'postgres' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' },
+ [DB_QUERY_TEXT]: { type: 'string', value: 'SELECT * FROM "User" WHERE "email" = $1' },
+ 'custom.requestHook': { type: 'string', value: 'called' },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 5445 },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT "User"' },
+ 'custom.requestHook.query': { type: 'string', value: 'SELECT * FROM "User" WHERE "email" = $1' },
+ 'custom.requestHook.database': { type: 'string', value: 'test_db' },
+ }),
+ name: 'SELECT "User"',
+ status: 'ok',
+ is_segment: false,
+ }),
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ [DB_NAMESPACE]: { type: 'string', value: 'test_db' },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'postgres' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'DROP TABLE' },
+ [DB_QUERY_TEXT]: { type: 'string', value: 'DROP TABLE "User"' },
+ 'custom.requestHook': { type: 'string', value: 'called' },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 5445 },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'DROP TABLE "User"' },
+ 'custom.requestHook.query': { type: 'string', value: 'DROP TABLE "User"' },
+ 'custom.requestHook.database': { type: 'string', value: 'test_db' },
+ }),
+ name: 'DROP TABLE "User"',
+ status: 'ok',
+ is_segment: false,
+ }),
+ ]),
+ };
+
+ createEsmTests(
+ __dirname,
+ 'scenario.mjs',
+ 'instrument-requestHook.mjs',
+ (createTestRunner, test) => {
+ test('should call requestHook when provided', { timeout: 60_000 }, async () => {
+ // The scenario's last query fails on purpose, so ignore its error event.
+ await createTestRunner()
+ .ignore('event')
+ .expect({
+ span: container => {
+ expect(container).toMatchObject(EXPECTED_SPANS);
+ // v4 only sets `host`/`port` on the channel context once the query is sent, after the hook ran.
+ for (const span of container.items.filter(span => span.attributes['custom.requestHook'])) {
+ expect(span.attributes['custom.requestHook.host']).toBeUndefined();
+ expect(span.attributes['custom.requestHook.port']).toBeUndefined();
+ }
+ },
+ })
+ .start()
+ .completed();
+ });
+ },
+ OPTIONS,
+ );
+ });
+
+ describe('url initialization', () => {
+ const EXPECTED_SPANS = {
+ items: expect.arrayContaining([
+ expect.objectContaining({ name: 'Test Transaction', is_segment: true }),
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ [DB_NAMESPACE]: { type: 'string', value: 'test_db' },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'postgres' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'CREATE TABLE' },
+ [DB_QUERY_TEXT]: {
+ type: 'string',
+ value:
+ 'CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(?) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id"))',
+ },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 5445 },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'CREATE TABLE "User"' },
+ }),
+ name: 'CREATE TABLE "User"',
+ status: 'ok',
+ is_segment: false,
+ }),
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ [DB_NAMESPACE]: { type: 'string', value: 'test_db' },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'postgres' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'INSERT' },
+ [DB_QUERY_TEXT]: { type: 'string', value: 'INSERT INTO "User" ("email", "name") VALUES ($1, ?)' },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 5445 },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'INSERT "User"' },
+ }),
+ name: 'INSERT "User"',
+ status: 'ok',
+ is_segment: false,
+ }),
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ [DB_NAMESPACE]: { type: 'string', value: 'test_db' },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'postgres' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' },
+ [DB_QUERY_TEXT]: { type: 'string', value: 'SELECT * FROM "User" WHERE "email" = $1' },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 5445 },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT "User"' },
+ }),
+ name: 'SELECT "User"',
+ status: 'ok',
+ is_segment: false,
+ }),
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ [DB_NAMESPACE]: { type: 'string', value: 'test_db' },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'postgres' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'DELETE' },
+ [DB_QUERY_TEXT]: { type: 'string', value: 'DELETE FROM "User" WHERE "email" = $1' },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 5445 },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'DELETE "User"' },
+ }),
+ name: 'DELETE "User"',
+ status: 'ok',
+ is_segment: false,
+ }),
+ ]),
+ };
+
+ createEsmTests(
+ __dirname,
+ 'scenario-url.mjs',
+ 'instrument.mjs',
+ (createTestRunner, test) => {
+ test('should instrument postgres package with URL initialization', { timeout: 90_000 }, async () => {
+ await createTestRunner().ignore('event').expect({ span: EXPECTED_SPANS }).start().completed();
+ });
+ },
+ OPTIONS,
+ );
+ });
+
+ describe('sql.unsafe()', () => {
+ const EXPECTED_SPANS = {
+ items: expect.arrayContaining([
+ expect.objectContaining({ name: 'Test Transaction', is_segment: true }),
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ [DB_NAMESPACE]: { type: 'string', value: 'test_db' },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'postgres' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'CREATE TABLE' },
+ [DB_QUERY_TEXT]: {
+ type: 'string',
+ value: 'CREATE TABLE "User" ("id" SERIAL NOT NULL, "email" TEXT NOT NULL, PRIMARY KEY ("id"))',
+ },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 5445 },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'CREATE TABLE "User"' },
+ }),
+ name: 'CREATE TABLE "User"',
+ status: 'ok',
+ is_segment: false,
+ }),
+ // sql.unsafe() with $1 placeholders - preserved per OTEL spec
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ [DB_NAMESPACE]: { type: 'string', value: 'test_db' },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'postgres' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'INSERT' },
+ [DB_QUERY_TEXT]: { type: 'string', value: 'INSERT INTO "User" ("email") VALUES ($1)' },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 5445 },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'INSERT "User"' },
+ }),
+ name: 'INSERT "User"',
+ status: 'ok',
+ is_segment: false,
+ }),
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ [DB_NAMESPACE]: { type: 'string', value: 'test_db' },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'postgres' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' },
+ [DB_QUERY_TEXT]: { type: 'string', value: 'SELECT * FROM "User" WHERE "email" = $1' },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 5445 },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT "User"' },
+ }),
+ name: 'SELECT "User"',
+ status: 'ok',
+ is_segment: false,
+ }),
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ [DB_NAMESPACE]: { type: 'string', value: 'test_db' },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'postgres' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'DROP TABLE' },
+ [DB_QUERY_TEXT]: { type: 'string', value: 'DROP TABLE "User"' },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 5445 },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'DROP TABLE "User"' },
+ }),
+ name: 'DROP TABLE "User"',
+ status: 'ok',
+ is_segment: false,
+ }),
+ ]),
+ };
+
+ createEsmTests(
+ __dirname,
+ 'scenario-unsafe.mjs',
+ 'instrument.mjs',
+ (createTestRunner, test) => {
+ test('should instrument sql.unsafe() queries', { timeout: 90_000 }, async () => {
+ // The last query fails on purpose, and its unhandled rejection also sends an error event, which can
+ // arrive before the transaction.
+ await createTestRunner().ignore('event').expect({ span: EXPECTED_SPANS }).start().completed();
+ });
+ },
+ OPTIONS,
+ );
+ });
+
+ describe('instrumentPostgresJsSql()', () => {
+ createEsmTests(
+ __dirname,
+ 'scenario-wrapper.mjs',
+ 'instrument.mjs',
+ (createTestRunner, test) => {
+ test('creates one span per query when the native channel fires too', { timeout: 60_000 }, async () => {
+ await createTestRunner()
+ .expect({
+ span: container => {
+ const segment = container.items.find(span => span.is_segment);
+ expect(segment?.name).toBe('Test Transaction');
+
+ const dbSpans = container.items.filter(span => span.attributes[SENTRY_OP]?.value === DB);
+ expect(dbSpans.map(span => span.attributes[DB_QUERY_TEXT]?.value)).toEqual([
+ 'SELECT ? AS one',
+ 'SELECT ? AS two',
+ ]);
+ for (const span of dbSpans) {
+ expect(span.parent_span_id).toBe(segment?.span_id);
+ expect(span.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: ORIGIN });
+ }
+ },
+ })
+ .start()
+ .completed();
+ });
+ },
+ OPTIONS,
+ );
+ });
+});
diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-multiple-endpoints.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-multiple-endpoints.mjs
new file mode 100644
index 000000000000..f7d9c9cd552c
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-multiple-endpoints.mjs
@@ -0,0 +1,29 @@
+import * as Sentry from '@sentry/node';
+import postgres from 'postgres';
+import { waitForConnection } from '@sentry-internal/node-integration-tests';
+
+const TEST_DB_URL = 'postgres://test:test@localhost:5444/test_db';
+
+// Both clients are created up front: with two known endpoints there is no single-endpoint fallback,
+// so the connection attributes can only come from the connection each query actually runs on.
+const testDbSql = postgres(TEST_DB_URL);
+const postgresDbSql = postgres('postgres://test:test@localhost:5444/postgres');
+
+async function run() {
+ const probeSql = postgres(TEST_DB_URL, { max: 1 });
+ await waitForConnection(() => probeSql`SELECT 1`);
+ await probeSql.end();
+
+ await Sentry.startSpan({ name: 'Test Transaction', op: 'transaction' }, async () => {
+ try {
+ await testDbSql`SELECT 1 AS test_db_new_connection`;
+ await testDbSql`SELECT 1 AS test_db_open_connection`;
+ await postgresDbSql`SELECT 1 AS postgres_db_new_connection`;
+ await postgresDbSql`SELECT 1 AS postgres_db_open_connection`;
+ } finally {
+ await Promise.all([testDbSql.end(), postgresDbSql.end()]);
+ }
+ });
+}
+
+run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts b/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts
index 8467dbfdaba1..61f9bc73a4ef 100644
--- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts
@@ -516,6 +516,42 @@ describeWithDockerCompose('postgresjs auto instrumentation', { workingDirectory:
});
});
+ describe('multiple endpoints', () => {
+ createEsmAndCjsTests(
+ __dirname,
+ 'scenario-multiple-endpoints.mjs',
+ 'instrument.mjs',
+ (createTestRunner, test, mode) => {
+ test.runIf(mode === 'esm' || supports({ runtimes: ['node', 'deno'] }))(
+ 'should set the connection attributes of the endpoint each query ran on',
+ { timeout: 90_000 },
+ async () => {
+ let receivedSpans: SerializedStreamedSpanContainer['items'] = [];
+ await createTestRunner()
+ .expect({
+ span: container => {
+ receivedSpans = container.items;
+ },
+ })
+ .unordered()
+ .start()
+ .completed();
+
+ const getNamespace = (alias: string): unknown =>
+ receivedSpans.find(span => String(span.attributes[DB_QUERY_TEXT]?.value).includes(alias))?.attributes[
+ DB_NAMESPACE
+ ];
+
+ expect(getNamespace('test_db_new_connection')).toEqual({ type: 'string', value: 'test_db' });
+ expect(getNamespace('test_db_open_connection')).toEqual({ type: 'string', value: 'test_db' });
+ expect(getNamespace('postgres_db_new_connection')).toEqual({ type: 'string', value: 'postgres' });
+ expect(getNamespace('postgres_db_open_connection')).toEqual({ type: 'string', value: 'postgres' });
+ },
+ );
+ },
+ );
+ });
+
describe('sql.unsafe()', () => {
const EXPECTED_SPANS = {
items: expect.arrayContaining([
diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/test.ts b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/test.ts
index 4c528b7c3aa5..66a5787cd6a1 100644
--- a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/test.ts
@@ -1,5 +1,6 @@
import {
DB_QUERY_SUMMARY,
+ DB_QUERY_TEXT,
DB_STATEMENT,
DB_SYSTEM,
SENTRY_KIND,
@@ -15,6 +16,13 @@ afterAll(() => {
cleanupChildProcesses();
});
+// Prisma 5 only reports the raw `db.statement`; `db.query.text` is the SDK's sanitized copy of it.
+const INSERT_USER_QUERY_TEXT =
+ 'INSERT INTO "public"."User" ("createdAt","email","name") VALUES ($1,$2,$3) RETURNING "public"."User"."id", "public"."User"."createdAt", "public"."User"."email", "public"."User"."name"';
+const SELECT_USER_QUERY_TEXT =
+ 'SELECT "public"."User"."id", "public"."User"."createdAt", "public"."User"."email", "public"."User"."name" FROM "public"."User" WHERE ?=? OFFSET $1';
+const DELETE_USER_QUERY_TEXT = 'DELETE FROM "public"."User" WHERE "public"."User"."email"::text LIKE $1';
+
const ADDITIONAL_DEPENDENCIES = {
'@prisma/client': '5.22.0',
prisma: '5.22.0',
@@ -82,6 +90,7 @@ function expectPrismaV5Spans(container: SerializedStreamedSpanContainer): void {
expect.objectContaining({
attributes: expect.objectContaining({
[DB_STATEMENT]: { type: 'string', value: expect.stringContaining('INSERT INTO') },
+ [DB_QUERY_TEXT]: { type: 'string', value: INSERT_USER_QUERY_TEXT },
[DB_QUERY_SUMMARY]: { type: 'string', value: 'INSERT "public"."User"' },
[DB_SYSTEM]: { type: 'string', value: 'postgresql' },
[SENTRY_KIND]: { type: 'string', value: 'client' },
@@ -94,6 +103,7 @@ function expectPrismaV5Spans(container: SerializedStreamedSpanContainer): void {
expect.objectContaining({
attributes: expect.objectContaining({
[DB_STATEMENT]: { type: 'string', value: expect.stringContaining('SELECT') },
+ [DB_QUERY_TEXT]: { type: 'string', value: SELECT_USER_QUERY_TEXT },
[DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT "public"."User"' },
[DB_SYSTEM]: { type: 'string', value: 'postgresql' },
[SENTRY_KIND]: { type: 'string', value: 'client' },
@@ -106,6 +116,7 @@ function expectPrismaV5Spans(container: SerializedStreamedSpanContainer): void {
expect.objectContaining({
attributes: expect.objectContaining({
[DB_STATEMENT]: { type: 'string', value: expect.stringContaining('DELETE') },
+ [DB_QUERY_TEXT]: { type: 'string', value: DELETE_USER_QUERY_TEXT },
[DB_QUERY_SUMMARY]: { type: 'string', value: 'DELETE "public"."User"' },
[DB_SYSTEM]: { type: 'string', value: 'postgresql' },
[SENTRY_KIND]: { type: 'string', value: 'client' },
@@ -124,15 +135,16 @@ function expectPrismaV5Spans(container: SerializedStreamedSpanContainer): void {
querySpans.map(span => ({
name: span.name,
summary: span.attributes[DB_QUERY_SUMMARY]?.value,
+ text: span.attributes[DB_QUERY_TEXT]?.value,
})),
).toEqual([
- { name: 'INSERT "public"."User"', summary: 'INSERT "public"."User"' },
- { name: 'SELECT "public"."User"', summary: 'SELECT "public"."User"' },
- { name: 'BEGIN', summary: 'BEGIN' },
- { name: 'INSERT "public"."User"', summary: 'INSERT "public"."User"' },
- { name: 'SELECT "public"."User"', summary: 'SELECT "public"."User"' },
- { name: 'COMMIT', summary: 'COMMIT' },
- { name: 'DELETE "public"."User"', summary: 'DELETE "public"."User"' },
+ { name: 'INSERT "public"."User"', summary: 'INSERT "public"."User"', text: INSERT_USER_QUERY_TEXT },
+ { name: 'SELECT "public"."User"', summary: 'SELECT "public"."User"', text: SELECT_USER_QUERY_TEXT },
+ { name: 'BEGIN', summary: 'BEGIN', text: 'BEGIN' },
+ { name: 'INSERT "public"."User"', summary: 'INSERT "public"."User"', text: INSERT_USER_QUERY_TEXT },
+ { name: 'SELECT "public"."User"', summary: 'SELECT "public"."User"', text: SELECT_USER_QUERY_TEXT },
+ { name: 'COMMIT', summary: 'COMMIT', text: 'COMMIT' },
+ { name: 'DELETE "public"."User"', summary: 'DELETE "public"."User"', text: DELETE_USER_QUERY_TEXT },
]);
// The raw engine span name must never leak through.
diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v7/test.ts b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v7/test.ts
index 9e11425ee539..8f6c8d78d34e 100644
--- a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v7/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v7/test.ts
@@ -106,7 +106,6 @@ describe('Prisma ORM v7 Tests', () => {
},
{
additionalDependencies: {
- '@prisma/adapter-pg': '7.2.0',
'@prisma/client': '7.2.0',
pg: '^8.11.0',
prisma: '7.2.0',
diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/test.ts b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/test.ts
index 6831e3123397..17263c38c517 100644
--- a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/test.ts
@@ -33,7 +33,6 @@ function getOperationSpans(container: SerializedStreamedSpanContainer): Serializ
// Prisma 8 has no tracing surface: the operation spans come from the orchestrion channels the runtime hook
// injects, and the `pg` spans underneath stand in for v7's `db_query` spans.
-// Prisma 8 requires Node 22.12 (`engines` of its `@prisma/cli-engine` peer), and on Node 20 it does not install.
describe.runIf(supports({ min: 22 }))('Prisma ORM v8 Tests', () => {
describeWithDockerCompose('Prisma ORM v8', { workingDirectory: [__dirname] }, () => {
createEsmAndCjsTests(
@@ -100,9 +99,6 @@ describe.runIf(supports({ min: 22 }))('Prisma ORM v8 Tests', () => {
testInstrumentedOperations();
},
{
- additionalDependencies: {
- '@prisma/orm-postgres': '8.0.0-rc.17',
- },
copyPaths: ['prisma'],
},
);
diff --git a/dev-packages/node-integration-tests/suites/tracing/typesafe/test.ts b/dev-packages/node-integration-tests/suites/tracing/typesafe/test.ts
index 0640f7282c7d..ab0b4b68bae2 100644
--- a/dev-packages/node-integration-tests/suites/tracing/typesafe/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/typesafe/test.ts
@@ -24,135 +24,113 @@ describe('TypeSafe integration', () => {
['automatic', 'scenario.mjs', 'instrument.mjs', 'instrument-no-recording.mjs'],
['manual', 'scenario-manual.mjs', 'instrument-manual.mjs', 'instrument-manual-no-recording.mjs'],
])('%s instrumentation', (_, scenarioFile, instrumentFile, noRecordingInstrumentFile) => {
- createEsmAndCjsTests(
- __dirname,
- scenarioFile,
- instrumentFile,
- (createRunner, test) => {
- test('creates decide spans for systemOne', async () => {
- await createRunner()
- .unordered()
- .expect({
- span: container => {
- const decideSpans = container.items.filter(span => span.attributes[SENTRY_OP]?.value === GEN_AI_DECIDE);
- expect(decideSpans).toHaveLength(4);
- for (const span of decideSpans) {
- expect(span.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.typesafe');
- expect(span.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('decide');
- expect(span.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe('typesafe');
- }
+ createEsmAndCjsTests(__dirname, scenarioFile, instrumentFile, (createRunner, test) => {
+ test('creates decide spans for systemOne', async () => {
+ await createRunner()
+ .unordered()
+ .expect({
+ span: container => {
+ const decideSpans = container.items.filter(span => span.attributes[SENTRY_OP]?.value === GEN_AI_DECIDE);
+ expect(decideSpans).toHaveLength(4);
+ for (const span of decideSpans) {
+ expect(span.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.typesafe');
+ expect(span.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('decide');
+ expect(span.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe('typesafe');
+ }
- const defaultModelSpan = decideSpans.find(span => span.name === 'decide jev-latest')!;
- expect(defaultModelSpan).toBeDefined();
- expect(defaultModelSpan.status).toBe('ok');
- expect(defaultModelSpan.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('jev-latest');
- expect(defaultModelSpan.attributes[GEN_AI_RESPONSE_MODEL]?.value).toBe('jev-1.13.0');
- expect(defaultModelSpan.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(275);
- expect(defaultModelSpan.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBe(20);
- expect(defaultModelSpan.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(295);
- expect(JSON.parse(defaultModelSpan.attributes[GEN_AI_INPUT_MESSAGES]?.value as string)).toEqual([
- {
- type: 'evaluation',
- state: 'I cannot log in, and I also want a refund for last month.',
- questions: {
- authIssue: { type: 'noul', instructions: 'Is there a login problem?' },
- department: {
- type: 'choice',
- instructions: 'Which team should handle this?',
- criteria: { billing: 'Charges and refunds', technical: 'Bugs and outages' },
- },
- urgency: {
- type: 'score',
- instructions: 'How urgent is this ticket?',
- criteria: ['low', 'medium', 'high'],
- },
+ const defaultModelSpan = decideSpans.find(span => span.name === 'decide jev-latest')!;
+ expect(defaultModelSpan).toBeDefined();
+ expect(defaultModelSpan.status).toBe('ok');
+ expect(defaultModelSpan.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('jev-latest');
+ expect(defaultModelSpan.attributes[GEN_AI_RESPONSE_MODEL]?.value).toBe('jev-1.13.0');
+ expect(defaultModelSpan.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(275);
+ expect(defaultModelSpan.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBe(20);
+ expect(defaultModelSpan.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(295);
+ expect(JSON.parse(defaultModelSpan.attributes[GEN_AI_INPUT_MESSAGES]?.value as string)).toEqual([
+ {
+ type: 'evaluation',
+ state: 'I cannot log in, and I also want a refund for last month.',
+ questions: {
+ authIssue: { type: 'noul', instructions: 'Is there a login problem?' },
+ department: {
+ type: 'choice',
+ instructions: 'Which team should handle this?',
+ criteria: { billing: 'Charges and refunds', technical: 'Bugs and outages' },
+ },
+ urgency: {
+ type: 'score',
+ instructions: 'How urgent is this ticket?',
+ criteria: ['low', 'medium', 'high'],
},
},
- ]);
- expect(JSON.parse(defaultModelSpan.attributes[GEN_AI_OUTPUT_MESSAGES]?.value as string)).toEqual([
- {
- type: 'evaluation',
- answers: {
- authIssue: { type: 'noul', noul: 0.98 },
- department: {
- type: 'choice',
- choice: 'billing',
- confidence: 0.28,
- probabilities: { billing: 0.64, technical: 0.36 },
- },
- urgency: {
- type: 'score',
- score: 1.58,
- confidence: 0.9,
- legend: { 0: 'low', 1: 'medium', 2: 'high' },
- probabilities: { 0: 0, 1: 0.42, 2: 0.58 },
- },
+ },
+ ]);
+ expect(JSON.parse(defaultModelSpan.attributes[GEN_AI_OUTPUT_MESSAGES]?.value as string)).toEqual([
+ {
+ type: 'evaluation',
+ answers: {
+ authIssue: { type: 'noul', noul: 0.98 },
+ department: {
+ type: 'choice',
+ choice: 'billing',
+ confidence: 0.28,
+ probabilities: { billing: 0.64, technical: 0.36 },
+ },
+ urgency: {
+ type: 'score',
+ score: 1.58,
+ confidence: 0.9,
+ legend: { 0: 'low', 1: 'medium', 2: 'high' },
+ probabilities: { 0: 0, 1: 0.42, 2: 0.58 },
},
},
- ]);
+ },
+ ]);
- const asResponseSpan = decideSpans.find(span => span.name === 'decide jev-1.13')!;
- expect(asResponseSpan).toBeDefined();
- expect(asResponseSpan.status).toBe('ok');
- expect(asResponseSpan.attributes[GEN_AI_RESPONSE_MODEL]?.value).toBe('jev-1.13.0');
+ const asResponseSpan = decideSpans.find(span => span.name === 'decide jev-1.13')!;
+ expect(asResponseSpan).toBeDefined();
+ expect(asResponseSpan.status).toBe('ok');
+ expect(asResponseSpan.attributes[GEN_AI_RESPONSE_MODEL]?.value).toBe('jev-1.13.0');
- const errorSpan = decideSpans.find(span => span.name === 'decide error-model')!;
- expect(errorSpan).toBeDefined();
- expect(errorSpan.status).toBe('error');
- expect(errorSpan.attributes[GEN_AI_RESPONSE_MODEL]).toBeUndefined();
- expect(errorSpan.attributes[GEN_AI_OUTPUT_MESSAGES]).toBeUndefined();
+ const errorSpan = decideSpans.find(span => span.name === 'decide error-model')!;
+ expect(errorSpan).toBeDefined();
+ expect(errorSpan.status).toBe('error');
+ expect(errorSpan.attributes[GEN_AI_RESPONSE_MODEL]).toBeUndefined();
+ expect(errorSpan.attributes[GEN_AI_OUTPUT_MESSAGES]).toBeUndefined();
- const validationErrorSpan = decideSpans.find(span => span.name === 'decide validation-error')!;
- expect(validationErrorSpan).toBeDefined();
- expect(validationErrorSpan.status).toBe('error');
- expect(validationErrorSpan.attributes[GEN_AI_OUTPUT_MESSAGES]).toBeUndefined();
- },
- })
- .start()
- .completed();
- });
- },
- {
- additionalDependencies: {
- '@typesafe-ai/sdk': '^0.6.0',
- },
- },
- );
+ const validationErrorSpan = decideSpans.find(span => span.name === 'decide validation-error')!;
+ expect(validationErrorSpan).toBeDefined();
+ expect(validationErrorSpan.status).toBe('error');
+ expect(validationErrorSpan.attributes[GEN_AI_OUTPUT_MESSAGES]).toBeUndefined();
+ },
+ })
+ .start()
+ .completed();
+ });
+ });
- createEsmAndCjsTests(
- __dirname,
- scenarioFile,
- noRecordingInstrumentFile,
- (createRunner, test) => {
- test('does not record inputs or outputs when recording is off', async () => {
- await createRunner()
- .unordered()
- .expect({
- span: container => {
- const decideSpans = container.items.filter(span => span.attributes[SENTRY_OP]?.value === GEN_AI_DECIDE);
- expect(decideSpans).toHaveLength(4);
- expect(decideSpans.filter(span => span.status === 'error')).toHaveLength(2);
- for (const span of decideSpans) {
- expect(span.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined();
- expect(span.attributes[GEN_AI_OUTPUT_MESSAGES]).toBeUndefined();
- // State, questions and answers must not come back through another attribute (e.g. an error message).
- // Only the attributes are checked (timestamps could match a number), and `probabilities` only
- // occurs in answers.
- expect(JSON.stringify(span.attributes)).not.toMatch(
- /cannot log in|Charges and refunds|probabilities/,
- );
- }
- },
- })
- .start()
- .completed();
- });
- },
- {
- additionalDependencies: {
- '@typesafe-ai/sdk': '^0.6.0',
- },
- },
- );
+ createEsmAndCjsTests(__dirname, scenarioFile, noRecordingInstrumentFile, (createRunner, test) => {
+ test('does not record inputs or outputs when recording is off', async () => {
+ await createRunner()
+ .unordered()
+ .expect({
+ span: container => {
+ const decideSpans = container.items.filter(span => span.attributes[SENTRY_OP]?.value === GEN_AI_DECIDE);
+ expect(decideSpans).toHaveLength(4);
+ expect(decideSpans.filter(span => span.status === 'error')).toHaveLength(2);
+ for (const span of decideSpans) {
+ expect(span.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined();
+ expect(span.attributes[GEN_AI_OUTPUT_MESSAGES]).toBeUndefined();
+ // State, questions and answers must not come back through another attribute (e.g. an error message).
+ // Only the attributes are checked (timestamps could match a number), and `probabilities` only
+ // occurs in answers.
+ expect(JSON.stringify(span.attributes)).not.toMatch(/cannot log in|Charges and refunds|probabilities/);
+ }
+ },
+ })
+ .start()
+ .completed();
+ });
+ });
});
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/scenario-runtime-context.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/scenario-runtime-context.mjs
new file mode 100644
index 000000000000..5cfef0834a8e
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/scenario-runtime-context.mjs
@@ -0,0 +1,91 @@
+import * as Sentry from '@sentry/node';
+import { generateText, stepCountIs, streamText, tool } from 'ai';
+import { MockLanguageModelV3, simulateReadableStream } from 'ai/test';
+import { z } from 'zod';
+
+const usage = {
+ inputTokens: { total: 10, noCache: 10, cached: 0 },
+ outputTokens: { total: 20, noCache: 20, cached: 0 },
+ totalTokens: { total: 30, noCache: 30, cached: 0 },
+};
+
+const tools = {
+ getWeather: tool({
+ description: 'Get the current weather for a location',
+ inputSchema: z.object({ location: z.string() }),
+ contextSchema: z.object({ units: z.string(), apiKey: z.string() }),
+ execute: async ({ location }) => `Weather in ${location}: Sunny`,
+ }),
+};
+
+async function run() {
+ await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
+ let callCount = 0;
+ await generateText({
+ model: new MockLanguageModelV3({
+ doGenerate: async () =>
+ callCount++ === 0
+ ? {
+ finishReason: { unified: 'tool-calls', raw: 'tool_calls' },
+ usage,
+ content: [
+ {
+ type: 'tool-call',
+ toolCallId: 'call-1',
+ toolName: 'getWeather',
+ input: JSON.stringify({ location: 'Vienna' }),
+ },
+ ],
+ warnings: [],
+ }
+ : {
+ finishReason: { unified: 'stop', raw: 'stop' },
+ usage,
+ content: [{ type: 'text', text: 'Sunny' }],
+ warnings: [],
+ },
+ }),
+ tools,
+ stopWhen: stepCountIs(3),
+ prompt: 'Weather in Vienna?',
+ runtimeContext: { requestId: 'req_generate', tenantId: 'acme', apiToken: 'secret-runtime' },
+ toolsContext: { getWeather: { units: 'metric', apiKey: 'secret-tool' } },
+ telemetry: {
+ isEnabled: true,
+ functionId: 'support-chat',
+ includeRuntimeContext: { requestId: true, tenantId: true },
+ includeToolsContext: { getWeather: { units: true } },
+ },
+ });
+
+ const result = streamText({
+ model: new MockLanguageModelV3({
+ doStream: async () => ({
+ stream: simulateReadableStream({
+ chunks: [
+ { type: 'stream-start', warnings: [] },
+ { type: 'text-start', id: '0' },
+ { type: 'text-delta', id: '0', delta: 'Hello!' },
+ { type: 'text-end', id: '0' },
+ { type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage },
+ ],
+ }),
+ }),
+ }),
+ prompt: 'Hi',
+ runtimeContext: { requestId: 'req_stream', apiToken: 'secret-runtime' },
+ telemetry: {
+ isEnabled: true,
+ functionId: 'support-stream',
+ includeRuntimeContext: { requestId: true },
+ },
+ });
+ for await (const _part of result.fullStream) {
+ void _part;
+ }
+ });
+
+ await Sentry.flush(2000);
+}
+
+run();
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 ea89bf21ddaf..0ac372b9c59d 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
@@ -26,7 +26,12 @@ import {
SENTRY_OP,
SENTRY_ORIGIN,
} from '@sentry/conventions/attributes';
-import { GEN_AI_DECIDE } from '@sentry/conventions/op';
+import {
+ GEN_AI_DECIDE,
+ GEN_AI_EXECUTE_TOOL,
+ GEN_AI_GENERATE_CONTENT,
+ GEN_AI_INVOKE_AGENT,
+} from '@sentry/conventions/op';
import { GEN_AI_TOOL_CALL_ID_ATTRIBUTE } from '../../../../../../packages/server-utils/src/ai/core/gen-ai-attributes';
import { cleanupChildProcesses, createEsmAndCjsTests, createEsmTests } from '../../../../utils/runner';
@@ -1429,3 +1434,62 @@ describe('Vercel AI telemetry metadata (v6)', () => {
{ additionalDependencies: { ai: '^6.0.0' } },
);
});
+
+describe('Vercel AI runtime and tool context (v7)', () => {
+ afterAll(() => {
+ cleanupChildProcesses();
+ });
+
+ createEsmTests(
+ __dirname,
+ 'scenario-runtime-context.mjs',
+ 'instrument.mjs',
+ (createRunner, test) => {
+ test('records the context keys included in telemetry as span attributes', async () => {
+ await createRunner()
+ .expect({
+ span: container => {
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai',
+ );
+ // Keys left out of `includeRuntimeContext`/`includeToolsContext` must not reach any span.
+ expect(JSON.stringify(spans)).not.toMatch(/secret-runtime|secret-tool|apiToken|apiKey/);
+
+ const generateSpans = spans.filter(
+ span => span.attributes['vercel.ai.settings.context.requestId']?.value === 'req_generate',
+ );
+ expect(generateSpans.map(span => span.attributes[SENTRY_OP]?.value).sort()).toEqual([
+ GEN_AI_EXECUTE_TOOL,
+ GEN_AI_GENERATE_CONTENT,
+ GEN_AI_GENERATE_CONTENT,
+ GEN_AI_INVOKE_AGENT,
+ ]);
+ for (const span of generateSpans) {
+ expect(span.attributes['vercel.ai.settings.context.tenantId']).toEqual({
+ type: 'string',
+ value: 'acme',
+ });
+ }
+
+ const toolSpan = generateSpans.find(span => span.attributes[SENTRY_OP]?.value === GEN_AI_EXECUTE_TOOL);
+ expect(toolSpan?.attributes['vercel.ai.tool_call.context.units']).toEqual({
+ type: 'string',
+ value: 'metric',
+ });
+
+ const streamSpans = spans.filter(
+ span => span.attributes['vercel.ai.settings.context.requestId']?.value === 'req_stream',
+ );
+ expect(streamSpans.map(span => span.attributes[SENTRY_OP]?.value).sort()).toEqual([
+ GEN_AI_GENERATE_CONTENT,
+ GEN_AI_INVOKE_AGENT,
+ ]);
+ },
+ })
+ .start()
+ .completed();
+ });
+ },
+ { additionalDependencies: { ai: '^7.0.154' } },
+ );
+});
diff --git a/dev-packages/optional-e2e-gh-action/package.json b/dev-packages/optional-e2e-gh-action/package.json
index 7a48130e3d4f..980cb58dbc2a 100644
--- a/dev-packages/optional-e2e-gh-action/package.json
+++ b/dev-packages/optional-e2e-gh-action/package.json
@@ -13,9 +13,9 @@
"lint": "oxlint . --type-aware",
"lint:fix": "oxlint . --fix --type-aware"
},
- "dependencies": {
- "@actions/core": "1.10.1",
- "@actions/github": "^5.0.0",
- "markdown-table": "3.0.3"
+ "devDependencies": {
+ "@actions/core": "3.0.1",
+ "@actions/github": "^9.1.1",
+ "markdown-table": "3.0.4"
}
}
diff --git a/dev-packages/rollup-utils/package.json b/dev-packages/rollup-utils/package.json
index 2d94d1c9d467..9d5b1d2d0d60 100644
--- a/dev-packages/rollup-utils/package.json
+++ b/dev-packages/rollup-utils/package.json
@@ -8,12 +8,5 @@
"license": "MIT",
"private": true,
"main": "./index.mjs",
- "dependencies": {
- "acorn": "^8.7.0",
- "recast": "^0.24.0"
- },
- "devDependencies": {
- "eslint-plugin-regexp": "^3.1.0"
- },
"type": "module"
}
diff --git a/dev-packages/size-limit-gh-action/package.json b/dev-packages/size-limit-gh-action/package.json
index 2fac041a0d7d..653fa8ffe05a 100644
--- a/dev-packages/size-limit-gh-action/package.json
+++ b/dev-packages/size-limit-gh-action/package.json
@@ -13,14 +13,12 @@
"lint": "oxlint . --type-aware",
"lint:fix": "oxlint . --fix --type-aware"
},
- "dependencies": {
+ "devDependencies": {
"@actions/artifact": "^6.2.1",
- "@actions/core": "1.10.1",
+ "@actions/core": "3.0.1",
"@actions/exec": "1.1.1",
- "@actions/github": "^5.0.0",
- "@actions/glob": "0.7.0",
- "@actions/io": "3.0.2",
+ "@actions/github": "^9.1.1",
"bytes-iec": "3.1.1",
- "markdown-table": "3.0.3"
+ "markdown-table": "3.0.4"
}
}
diff --git a/dev-packages/test-utils/package.json b/dev-packages/test-utils/package.json
index c4a642459c0b..287581fbc397 100644
--- a/dev-packages/test-utils/package.json
+++ b/dev-packages/test-utils/package.json
@@ -69,7 +69,6 @@
"@sentry-internal/rollup-utils": "11.10.0",
"@sentry/core": "11.10.0",
"@types/express": "^4.17.21",
- "@types/ws": "^8.18.1",
- "eslint-plugin-regexp": "^3.1.0"
+ "@types/ws": "^8.18.1"
}
}
diff --git a/docs/supporting-a-new-node-version.md b/docs/supporting-a-new-node-version.md
index d952742d7503..4720570d1866 100644
--- a/docs/supporting-a-new-node-version.md
+++ b/docs/supporting-a-new-node-version.md
@@ -87,6 +87,9 @@ Repo: [sentry-javascript](https://github.com/getsentry/sentry-javascript)
Reference: [#20710](https://github.com/getsentry/sentry-javascript/pull/20710)
1. Add the version to the CI test matrix in `.github/workflows/build.yml` to every `node: [20.19, 22, 24, 26]` entry.
+ While the new major is an alpha, the matrix has it as a range (e.g. `'^27.0.0-0'`) with `continue-on-error`, and a
+ `Compile native modules` step compiles the native modules from source for it. When the major is released, replace
+ the range with the major and remove `continue-on-error` and that step.
2. Bump the native module dependencies to the versions released in Steps 1 and 2:
- [ ] `@sentry-internal/node-cpu-profiler` in `packages/profiling-node`
diff --git a/package.json b/package.json
index 954928582f62..dde3b52dcf3a 100644
--- a/package.json
+++ b/package.json
@@ -139,7 +139,7 @@
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-replace": "^5.0.5",
- "@rollup/plugin-terser": "^0.4.4",
+ "@rollup/plugin-terser": "^1.0.2",
"@sentry-internal/node-integration-tests": "workspace:*",
"@sentry/eslint-plugin-sdk": "workspace:*",
"@sentry/sveltekit": "workspace:*",
@@ -166,10 +166,10 @@
"rollup-plugin-license": "^3.7.1",
"size-limit": "~12.1.0",
"tslib": "^2.4.1",
- "tsx": "^4.23.0",
+ "tsx": "^4.23.15",
"typescript": "~7.0.2",
"vitest": "^4.1.11",
- "webpack": "^5.104.1",
+ "webpack": "^5.112.2",
"yalc": "^1.0.0-pre.53"
},
"version": "0.0.0",
diff --git a/packages/angular/package.json b/packages/angular/package.json
index d01527f800a3..9692ca891583 100644
--- a/packages/angular/package.json
+++ b/packages/angular/package.json
@@ -23,7 +23,7 @@
"dependencies": {
"@sentry/browser": "11.10.0",
"@sentry/core": "11.10.0",
- "@sentry/conventions": "^0.29.0",
+ "@sentry/conventions": "^0.30.0",
"tslib": "^2.4.1"
},
"devDependencies": {
diff --git a/packages/angular/src/tracing.ts b/packages/angular/src/tracing.ts
index 3a50bf232339..c5884695121c 100644
--- a/packages/angular/src/tracing.ts
+++ b/packages/angular/src/tracing.ts
@@ -159,9 +159,7 @@ export class TraceService implements OnDestroy {
[SENTRY_OP]: ROUTER,
[SENTRY_ORIGIN]: 'auto.ui.angular',
[URL_FULL]: strippedUrl,
- ...(navigationEvent.navigationTrigger && {
- navigationTrigger: navigationEvent.navigationTrigger,
- }),
+ navigationTrigger: navigationEvent.navigationTrigger,
},
}),
) || null;
diff --git a/packages/astro/package.json b/packages/astro/package.json
index 977b9911ca51..9dbd4db2b5c5 100644
--- a/packages/astro/package.json
+++ b/packages/astro/package.json
@@ -32,6 +32,12 @@
"import": "./build/esm/index.client.js",
"require": "./build/cjs/index.server.js"
},
+ "./integration": {
+ "types": "./build/types/index.integration.d.ts",
+ "node": "./build/esm/index.integration.js",
+ "import": "./build/esm/index.integration.js",
+ "require": "./build/cjs/index.integration.js"
+ },
"./middleware": {
"types": "./build/types/integration/middleware/index.d.ts",
"node": "./build/esm/integration/middleware/index.js",
@@ -53,7 +59,7 @@
"dependencies": {
"@sentry/browser": "11.10.0",
"@sentry/core": "11.10.0",
- "@sentry/conventions": "^0.29.0",
+ "@sentry/conventions": "^0.30.0",
"@sentry/node": "11.10.0",
"@sentry/server-runtime-injection": "11.10.0",
"@sentry/server-utils": "11.10.0",
@@ -62,7 +68,7 @@
"devDependencies": {
"@sentry-internal/rollup-utils": "11.10.0",
"astro": "^4.16.19",
- "vite": "^8.3.1"
+ "vite": "^8.3.5"
},
"scripts": {
"build": "run-p build:transpile build:types",
diff --git a/packages/astro/rollup.npm.config.mjs b/packages/astro/rollup.npm.config.mjs
index e4694afbff88..07a25d8690b1 100644
--- a/packages/astro/rollup.npm.config.mjs
+++ b/packages/astro/rollup.npm.config.mjs
@@ -2,7 +2,12 @@ import { makeBaseNPMConfig, makeNPMConfigVariants, makeOrchestrionLoader } from
const variants = makeNPMConfigVariants(
makeBaseNPMConfig({
- entrypoints: ['src/index.server.ts', 'src/index.client.ts', 'src/integration/middleware/index.ts'],
+ entrypoints: [
+ 'src/index.server.ts',
+ 'src/index.client.ts',
+ 'src/index.integration.ts',
+ 'src/integration/middleware/index.ts',
+ ],
packageSpecificConfig: {
output: {
dynamicImportInCjs: true,
diff --git a/packages/astro/src/index.integration.ts b/packages/astro/src/index.integration.ts
new file mode 100644
index 000000000000..64465a872d83
--- /dev/null
+++ b/packages/astro/src/index.integration.ts
@@ -0,0 +1,5 @@
+import { sentryAstro } from './integration';
+
+export { sentryAstro };
+
+export default sentryAstro;
diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts
index 69a5899ae619..f407a5723a5b 100644
--- a/packages/astro/src/index.server.ts
+++ b/packages/astro/src/index.server.ts
@@ -3,7 +3,7 @@
// Vite puts these exports into a `default` property (Sentry.default) rather than
// on the top - level namespace.
-import { sentryAstro } from './integration';
+import { sentryAstro as _sentryAstro } from './integration';
import { handleRequest } from './server/middleware';
// Hence, we export everything from the Node SDK explicitly:
@@ -19,6 +19,7 @@ export {
googleGenAIIntegration,
// eslint-disable-next-line typescript/no-deprecated
disableAnrDetectionForCallback,
+ BullMQTelemetry,
captureCheckIn,
captureConsoleIntegration,
captureEvent,
@@ -48,6 +49,9 @@ export {
fsIntegration,
functionToStringIntegration,
genericPoolIntegration,
+ cronJobIntegration,
+ nodeCronIntegration,
+ nodeScheduleIntegration,
getActiveSpan,
getAutoPerformanceIntegrations,
getClient,
@@ -93,6 +97,7 @@ export {
nativeNodeFetchIntegration,
NodeClient,
nodeContextIntegration,
+ awsLambdaWebIntegration,
onUncaughtExceptionIntegration,
onUnhandledRejectionIntegration,
mistralAIIntegration,
@@ -215,6 +220,13 @@ export {
export { init } from './server/sdk';
+/**
+ * @deprecated Import the integration from `@sentry/astro/integration` instead.
+ * This export will be removed in the next major version.
+ */
+const sentryAstro = _sentryAstro;
+
+// oxlint-disable-next-line typescript/no-deprecated
export default sentryAstro;
// This exports the `handleRequest` middleware for manual usage
diff --git a/packages/astro/src/index.types.ts b/packages/astro/src/index.types.ts
index c08b0b7199f4..c6287009e916 100644
--- a/packages/astro/src/index.types.ts
+++ b/packages/astro/src/index.types.ts
@@ -46,4 +46,5 @@ export declare const statsigIntegration: typeof clientSdk.statsigIntegration;
export declare const unleashIntegration: typeof clientSdk.unleashIntegration;
export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration;
+// oxlint-disable-next-line typescript/no-deprecated
export default sentryAstro;
diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts
index 66b5de50861b..76bae75d5973 100644
--- a/packages/astro/src/integration/index.ts
+++ b/packages/astro/src/integration/index.ts
@@ -248,7 +248,8 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => {
if (isSSR && shouldAddMiddleware) {
addMiddleware({
order: 'pre',
- entrypoint: serverInitSnippet ? SENTRY_MIDDLEWARE_MODULE : '@sentry/astro/middleware',
+ entrypoint:
+ serverInitSnippet && !isCloudflareWorkers ? SENTRY_MIDDLEWARE_MODULE : '@sentry/astro/middleware',
});
}
},
diff --git a/packages/astro/src/server/sdk.ts b/packages/astro/src/server/sdk.ts
index 6ba2703bc994..98910a1db2fd 100644
--- a/packages/astro/src/server/sdk.ts
+++ b/packages/astro/src/server/sdk.ts
@@ -1,13 +1,24 @@
+import { SENTRY_ORIGIN } from '@sentry/conventions/attributes';
import { HTTP_SERVER } from '@sentry/conventions/op';
import { applySdkMetadata } from '@sentry/core';
import type { NodeClient, NodeOptions } from '@sentry/node';
import { init as initNodeSdk } from '@sentry/node';
+declare const EdgeRuntime: string | undefined;
+declare const Deno: unknown;
+declare const Netlify: unknown;
+
/**
*
* @param options
*/
export function init(options: NodeOptions): NodeClient | undefined {
+ // With `edgeMiddleware` enabled, Vercel and Netlify bundle our middleware (which imports the
+ // server init) into a separate edge function, where the Node SDK can't run.
+ if (isEdgeRuntime()) {
+ return undefined;
+ }
+
const opts = {
...options,
};
@@ -20,8 +31,13 @@ export function init(options: NodeOptions): NodeClient | undefined {
// we want to drop them
// this is the case with http.server spans of prerendered pages
// we do not care about those, as they are effectively static
- { op: HTTP_SERVER, attributes: { 'sentry.origin': 'auto.http.http_server' } },
+ { op: HTTP_SERVER, attributes: { [SENTRY_ORIGIN]: 'auto.http.http_server' } },
];
return initNodeSdk(opts);
}
+
+function isEdgeRuntime(): boolean {
+ // Netlify also defines the `Netlify` global in its Node functions, so only Deno + Netlify is its edge.
+ return typeof EdgeRuntime === 'string' || (typeof Netlify !== 'undefined' && typeof Deno !== 'undefined');
+}
diff --git a/packages/astro/test/integration/index.files.test.ts b/packages/astro/test/integration/index.files.test.ts
index 125333ede7be..05cf524543b8 100644
--- a/packages/astro/test/integration/index.files.test.ts
+++ b/packages/astro/test/integration/index.files.test.ts
@@ -37,4 +37,22 @@ describe('sentryAstro integration', () => {
/^import ".*\/sentry.server.config.js"/,
);
});
+
+ it('imports the default server init file from the middleware entrypoint', async () => {
+ const addMiddleware = vi.fn();
+ const updateConfig = vi.fn();
+ const setup = sentryAstro({}).hooks['astro:config:setup'] as (params: unknown) => Promise;
+
+ await setup({ updateConfig, injectScript, addMiddleware, config: { output: 'server' } });
+
+ expect(addMiddleware).toHaveBeenCalledWith({
+ order: 'pre',
+ entrypoint: 'virtual:@sentry/astro/middleware',
+ });
+
+ const plugin = updateConfig.mock.calls
+ .flatMap(([updatedConfig]) => updatedConfig.vite?.plugins ?? [])
+ .find((vitePlugin: { name?: string }) => vitePlugin?.name === 'sentry-astro-server-init');
+ expect(plugin.load('\0virtual:@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 62481501bd23..870c28dc5b30 100644
--- a/packages/astro/test/integration/index.test.ts
+++ b/packages/astro/test/integration/index.test.ts
@@ -690,6 +690,24 @@ describe('sentryAstro integration', () => {
});
});
+ it('keeps the default middleware entrypoint for the Cloudflare Workers adapter', async () => {
+ const addMiddleware = vi.fn();
+ const setup = sentryAstro({ serverInitPath: 'my-server-init-path.js' }).hooks['astro:config:setup'] as (
+ params: unknown,
+ ) => Promise;
+
+ await setup({
+ ...baseConfigHookObject,
+ config: { output: 'server', adapter: { name: '@astrojs/cloudflare' } },
+ addMiddleware,
+ updateConfig,
+ injectScript,
+ });
+
+ expect(addMiddleware).toHaveBeenCalledWith({ order: 'pre', entrypoint: '@sentry/astro/middleware' });
+ expect(getServerInit(updateConfig)).toContain('my-server-init-path.js');
+ });
+
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();
diff --git a/packages/astro/test/server/index.server.test.ts b/packages/astro/test/server/index.server.test.ts
index 4a89ebe16a13..6c1ed2de3127 100644
--- a/packages/astro/test/server/index.server.test.ts
+++ b/packages/astro/test/server/index.server.test.ts
@@ -1,9 +1,10 @@
import { describe, expect, it } from 'vitest';
+import sentryAstroIntegration from '../../src/index.integration';
+// oxlint-disable-next-line typescript/no-deprecated
import sentryAstro from '../../src/index.server';
describe('server SDK', () => {
it('exports the astro integration as a default export', () => {
- const integration = sentryAstro();
- expect(integration.name).toBe('@sentry/astro');
+ expect(sentryAstro).toBe(sentryAstroIntegration);
});
});
diff --git a/packages/astro/test/server/sdk.test.ts b/packages/astro/test/server/sdk.test.ts
index 6de7cab9f38e..e9d15f50cb7f 100644
--- a/packages/astro/test/server/sdk.test.ts
+++ b/packages/astro/test/server/sdk.test.ts
@@ -10,10 +10,42 @@ describe('Sentry server SDK', () => {
describe('init', () => {
afterEach(() => {
vi.clearAllMocks();
+ vi.unstubAllGlobals();
getMainCarrier().__SENTRY__ = undefined;
});
+ it("doesn't initialize the SDK in the Vercel Edge runtime", () => {
+ vi.stubGlobal('EdgeRuntime', 'edge-runtime');
+
+ expect(init({})).toBeUndefined();
+ expect(nodeInit).not.toHaveBeenCalled();
+ });
+
+ it("doesn't initialize the SDK in Netlify edge functions", () => {
+ vi.stubGlobal('Netlify', {});
+ vi.stubGlobal('Deno', {});
+
+ expect(init({})).toBeUndefined();
+ expect(nodeInit).not.toHaveBeenCalled();
+ });
+
+ it('initializes the SDK in Netlify Node functions', () => {
+ vi.stubGlobal('Netlify', {});
+
+ init({});
+
+ expect(nodeInit).toHaveBeenCalledTimes(1);
+ });
+
+ it('initializes the SDK in Deno', () => {
+ vi.stubGlobal('Deno', {});
+
+ init({});
+
+ expect(nodeInit).toHaveBeenCalledTimes(1);
+ });
+
it('adds Astro metadata to the SDK options', () => {
expect(nodeInit).not.toHaveBeenCalled();
diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json
index 189ffcbd9a86..b23937b2cf40 100644
--- a/packages/aws-serverless/package.json
+++ b/packages/aws-serverless/package.json
@@ -66,12 +66,12 @@
"access": "public"
},
"dependencies": {
- "@sentry/conventions": "^0.29.0",
+ "@sentry/conventions": "^0.30.0",
"@sentry/core": "11.10.0",
"@sentry/node": "11.10.0",
"@sentry/server-runtime-injection": "11.10.0",
"@sentry/server-utils": "11.10.0",
- "@types/aws-lambda": "^8.10.161"
+ "@types/aws-lambda": "^8.10.164"
},
"devDependencies": {
"@sentry-internal/rollup-utils": "11.10.0",
diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts
index 095858178815..f7310775b554 100644
--- a/packages/aws-serverless/src/index.ts
+++ b/packages/aws-serverless/src/index.ts
@@ -78,6 +78,7 @@ export {
type NodeRuntimeMetricsOptions,
contextLinesIntegration,
nodeContextIntegration,
+ awsLambdaWebIntegration,
localVariablesIntegration,
requestDataIntegration,
functionToStringIntegration,
@@ -118,6 +119,9 @@ export {
firebaseIntegration,
fsIntegration,
genericPoolIntegration,
+ cronJobIntegration,
+ nodeCronIntegration,
+ nodeScheduleIntegration,
graphqlIntegration,
knexIntegration,
kafkaIntegration,
@@ -170,6 +174,7 @@ export {
zodErrorsIntegration,
profiler,
amqplibIntegration,
+ BullMQTelemetry,
anthropicAIIntegration,
googleGenAIIntegration,
vercelAIIntegration,
diff --git a/packages/aws-serverless/test/lambda-extension-index.test.ts b/packages/aws-serverless/test/lambda-extension-index.test.ts
index 0927b1c75469..76c813538ebb 100644
--- a/packages/aws-serverless/test/lambda-extension-index.test.ts
+++ b/packages/aws-serverless/test/lambda-extension-index.test.ts
@@ -1,9 +1,12 @@
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
-beforeEach(() => {
+let extension: Awaited>;
+
+beforeEach(async () => {
vi.resetModules();
vi.useFakeTimers();
vi.setSystemTime(0);
+ extension = await setupExtension();
});
afterEach(() => {
@@ -24,7 +27,7 @@ async function setupExtension() {
test.each(['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ETIMEDOUT', 'EAI_AGAIN'])(
'recovers from %s without exiting',
async code => {
- const { next, exit, log } = await setupExtension();
+ const { next, exit, log } = extension;
next.mockRejectedValueOnce(Object.assign(new Error('Connection failed'), { code })).mockResolvedValueOnce();
const { POLL_RETRY_DELAYS } = await import('../src/lambda-extension/index');
@@ -41,7 +44,7 @@ test.each(['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ETIMEDOUT', 'EAI_AGAIN'])(
);
test('logs and exits after exhausting consecutive connection retries', async () => {
- const { next, exit, log } = await setupExtension();
+ const { next, exit, log } = extension;
const error = Object.assign(new Error('Connection reset'), { code: 'ECONNRESET' });
const attempts: number[] = [];
next.mockImplementation(async () => {
@@ -62,7 +65,7 @@ test('logs and exits after exhausting consecutive connection retries', async ()
});
test('resets the retry budget after a successful poll', async () => {
- const { next, exit } = await setupExtension();
+ const { next, exit } = extension;
const error = Object.assign(new Error('Connection reset'), { code: 'ECONNRESET' });
next
.mockRejectedValueOnce(error)
@@ -86,7 +89,7 @@ test.each([
new Error('Failed to advance to next event: Container error'),
Object.assign(new Error('Invalid URL'), { code: 'ERR_INVALID_URL' }),
])('logs and exits immediately for $message', async error => {
- const { next, log, exit } = await setupExtension();
+ const { next, log, exit } = extension;
next.mockRejectedValue(error);
await import('../src/lambda-extension/index');
diff --git a/packages/browser-utils/package.json b/packages/browser-utils/package.json
index ef4b92a76b94..729278fdef68 100644
--- a/packages/browser-utils/package.json
+++ b/packages/browser-utils/package.json
@@ -34,7 +34,7 @@
},
"dependencies": {
"@sentry/core": "11.10.0",
- "@sentry/conventions": "^0.29.0",
+ "@sentry/conventions": "^0.30.0",
"web-vitals": "^6.2.3"
},
"devDependencies": {
diff --git a/packages/browser-utils/src/performance/elementTiming.ts b/packages/browser-utils/src/performance/elementTiming.ts
index 5b8e5f01218e..1663f5096c00 100644
--- a/packages/browser-utils/src/performance/elementTiming.ts
+++ b/packages/browser-utils/src/performance/elementTiming.ts
@@ -1,3 +1,13 @@
+import {
+ SENTRY_ORIGIN,
+ UI_ELEMENT_HEIGHT,
+ UI_ELEMENT_ID,
+ UI_ELEMENT_IDENTIFIER,
+ UI_ELEMENT_PAINT_TYPE,
+ UI_ELEMENT_TYPE,
+ UI_ELEMENT_URL,
+ UI_ELEMENT_WIDTH,
+} from '@sentry/conventions/attributes';
import type { IntegrationFn } from '@sentry/core';
import { browserPerformanceTimeOrigin, defineIntegration, filterCollectedUrl, metrics } from '@sentry/core';
import { addPerformanceInstrumentationHandler } from '../instrumentation/performanceObserver';
@@ -41,32 +51,32 @@ const _elementTimingIntegration = (() => {
const loadTime = elementEntry.loadTime;
const metricAttributes: Record = {
- 'sentry.origin': 'auto.ui.browser.element_timing',
- 'ui.element.identifier': identifier,
+ [SENTRY_ORIGIN]: 'auto.ui.browser.element_timing',
+ [UI_ELEMENT_IDENTIFIER]: identifier,
};
if (paintType) {
- metricAttributes['ui.element.paint_type'] = paintType;
+ metricAttributes[UI_ELEMENT_PAINT_TYPE] = paintType;
}
if (elementEntry.id) {
- metricAttributes['ui.element.id'] = elementEntry.id;
+ metricAttributes[UI_ELEMENT_ID] = elementEntry.id;
}
if (elementEntry.element) {
- metricAttributes['ui.element.type'] = elementEntry.element.tagName.toLowerCase();
+ metricAttributes[UI_ELEMENT_TYPE] = elementEntry.element.tagName.toLowerCase();
}
if (elementEntry.url) {
- metricAttributes['ui.element.url'] = filterCollectedUrl(elementEntry.url);
+ metricAttributes[UI_ELEMENT_URL] = filterCollectedUrl(elementEntry.url);
}
if (elementEntry.naturalWidth) {
- metricAttributes['ui.element.width'] = elementEntry.naturalWidth;
+ metricAttributes[UI_ELEMENT_WIDTH] = elementEntry.naturalWidth;
}
if (elementEntry.naturalHeight) {
- metricAttributes['ui.element.height'] = elementEntry.naturalHeight;
+ metricAttributes[UI_ELEMENT_HEIGHT] = elementEntry.naturalHeight;
}
if (renderTime > 0) {
diff --git a/packages/browser-utils/src/performance/entries.ts b/packages/browser-utils/src/performance/entries.ts
index 905fa44426f7..c8c020ef6982 100644
--- a/packages/browser-utils/src/performance/entries.ts
+++ b/packages/browser-utils/src/performance/entries.ts
@@ -28,6 +28,17 @@ import {
URL_FULL,
URL_SCHEME,
SENTRY_ORIGIN,
+ BROWSER_SCRIPT_INVOKER,
+ BROWSER_SCRIPT_INVOKER_TYPE,
+ BROWSER_SCRIPT_SOURCE_CHAR_POSITION,
+ CONNECTIONTYPE,
+ DEVICEMEMORY,
+ DEVICE_MEMORY_ESTIMATED_CAPACITY,
+ DEVICE_PROCESSOR_COUNT,
+ EFFECTIVECONNECTIONTYPE,
+ HARDWARECONCURRENCY,
+ HTTP_RESPONSE_BODY_DECODED_SIZE,
+ RESOURCE_RENDER_BLOCKING_STATUS,
} from '@sentry/conventions/attributes';
import {
BROWSER_CACHE,
@@ -169,10 +180,14 @@ export function startTrackingLongAnimationFrames(): void {
[SENTRY_ORIGIN]: 'auto.ui.browser.metrics',
};
- const initialScript = entry.scripts[0];
- const { invoker, invokerType, sourceURL, sourceFunctionName, sourceCharPosition } = initialScript;
- attributes['browser.script.invoker'] = invoker;
- attributes['browser.script.invoker_type'] = invokerType;
+ // The first script can be a short listener (e.g. the SDK's own INP listener on `mousedown`)
+ // that runs before the script that actually blocked the frame.
+ const longestScript = entry.scripts.reduce((longest, script) =>
+ script.duration > longest.duration ? script : longest,
+ );
+ const { invoker, invokerType, sourceURL, sourceFunctionName, sourceCharPosition } = longestScript;
+ attributes[BROWSER_SCRIPT_INVOKER] = invoker;
+ attributes[BROWSER_SCRIPT_INVOKER_TYPE] = invokerType;
if (sourceURL) {
attributes[CODE_FILE_PATH] = sourceURL;
}
@@ -180,7 +195,7 @@ export function startTrackingLongAnimationFrames(): void {
attributes[CODE_FUNCTION_NAME] = sourceFunctionName;
}
if (sourceCharPosition !== -1) {
- attributes['browser.script.source_char_position'] = sourceCharPosition;
+ attributes[BROWSER_SCRIPT_SOURCE_CHAR_POSITION] = sourceCharPosition;
}
startAndEndSpan(parent, startTime, startTime + duration, {
@@ -478,10 +493,10 @@ export function _addResourceSpans(
['transferSize', HTTP_RESPONSE_SIZE],
['encodedBodySize', HTTP_RESPONSE_BODY_SIZE],
- ['decodedBodySize', 'http.response.body.decoded_size'],
+ ['decodedBodySize', HTTP_RESPONSE_BODY_DECODED_SIZE],
// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/renderBlockingStatus
- ['renderBlockingStatus', 'resource.render_blocking_status'],
+ ['renderBlockingStatus', RESOURCE_RENDER_BLOCKING_STATUS],
// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/deliveryType
['deliveryType', 'http.response_delivery_type'],
@@ -516,13 +531,15 @@ function _trackNavigator(span: Span, spanStreamingEnabled: boolean | undefined):
if (connection) {
if (connection.effectiveType) {
span.setAttribute(
- spanStreamingEnabled ? NETWORK_CONNECTION_EFFECTIVE_TYPE : 'effectiveConnectionType',
+ // oxlint-disable-next-line typescript/no-deprecated -- Preserve the existing attribute name.
+ spanStreamingEnabled ? NETWORK_CONNECTION_EFFECTIVE_TYPE : EFFECTIVECONNECTIONTYPE,
connection.effectiveType,
);
}
if (connection.type) {
- span.setAttribute(spanStreamingEnabled ? NETWORK_CONNECTION_TYPE : 'connectionType', connection.type);
+ // oxlint-disable-next-line typescript/no-deprecated -- Preserve the existing attribute name.
+ span.setAttribute(spanStreamingEnabled ? NETWORK_CONNECTION_TYPE : CONNECTIONTYPE, connection.type);
}
if (isMeasurementValue(connection.rtt)) {
@@ -538,17 +555,19 @@ function _trackNavigator(span: Span, spanStreamingEnabled: boolean | undefined):
if (isMeasurementValue(navigator.deviceMemory)) {
if (spanStreamingEnabled) {
- span.setAttribute('device.memory.estimated_capacity', navigator.deviceMemory);
+ span.setAttribute(DEVICE_MEMORY_ESTIMATED_CAPACITY, navigator.deviceMemory);
} else {
- span.setAttribute('deviceMemory', `${navigator.deviceMemory} GB`);
+ // oxlint-disable-next-line typescript/no-deprecated -- Preserve the existing attribute name.
+ span.setAttribute(DEVICEMEMORY, `${navigator.deviceMemory} GB`);
}
}
if (isMeasurementValue(navigator.hardwareConcurrency)) {
if (spanStreamingEnabled) {
- span.setAttribute('device.processor_count', navigator.hardwareConcurrency);
+ span.setAttribute(DEVICE_PROCESSOR_COUNT, navigator.hardwareConcurrency);
} else {
- span.setAttribute('hardwareConcurrency', String(navigator.hardwareConcurrency));
+ // oxlint-disable-next-line typescript/no-deprecated -- Preserve the existing attribute name.
+ span.setAttribute(HARDWARECONCURRENCY, String(navigator.hardwareConcurrency));
}
}
}
diff --git a/packages/browser-utils/src/performance/interactions.ts b/packages/browser-utils/src/performance/interactions.ts
index 33913a40c619..3112c7b6a4ac 100644
--- a/packages/browser-utils/src/performance/interactions.ts
+++ b/packages/browser-utils/src/performance/interactions.ts
@@ -217,10 +217,10 @@ function registerInteractionListener(
[SENTRY_SEGMENT_NAME_SOURCE]: hasSpanStreaming ? streamedNameSource : latestRoute.source || 'url',
[SENTRY_ORIGIN]: 'auto.browser.interactions',
...(hasSpanStreaming && { [SENTRY_SEGMENT_NAME]: streamedName }),
- ...(latestRoute.routeId && { [ROUTER_NAVIGATION_ROUTE_ID]: latestRoute.routeId }),
- ...(latestRoute.urlTemplate && { [URL_TEMPLATE]: latestRoute.urlTemplate }),
- ...(latestRoute.httpRoute && { [HTTP_ROUTE]: latestRoute.httpRoute }),
- ...(latestRoute.urlPath && { [URL_PATH]: latestRoute.urlPath }),
+ [ROUTER_NAVIGATION_ROUTE_ID]: latestRoute.routeId,
+ [URL_TEMPLATE]: latestRoute.urlTemplate,
+ [HTTP_ROUTE]: latestRoute.httpRoute,
+ [URL_PATH]: latestRoute.urlPath,
...(latestRoute.urlFull && { [URL_FULL]: filterCollectedUrl(latestRoute.urlFull) }),
},
},
diff --git a/packages/browser-utils/src/performance/resourceTiming.ts b/packages/browser-utils/src/performance/resourceTiming.ts
index 172a454f31da..8fb0b459b87b 100644
--- a/packages/browser-utils/src/performance/resourceTiming.ts
+++ b/packages/browser-utils/src/performance/resourceTiming.ts
@@ -1,3 +1,20 @@
+import {
+ HTTP_REQUEST_CONNECTION_END,
+ HTTP_REQUEST_CONNECT_START,
+ HTTP_REQUEST_DOMAIN_LOOKUP_END,
+ HTTP_REQUEST_DOMAIN_LOOKUP_START,
+ HTTP_REQUEST_FETCH_START,
+ HTTP_REQUEST_REDIRECT_END,
+ HTTP_REQUEST_REDIRECT_START,
+ HTTP_REQUEST_REQUEST_START,
+ HTTP_REQUEST_RESPONSE_END,
+ HTTP_REQUEST_RESPONSE_START,
+ HTTP_REQUEST_SECURE_CONNECTION_START,
+ HTTP_REQUEST_TIME_TO_FIRST_BYTE,
+ HTTP_REQUEST_WORKER_START,
+ NETWORK_PROTOCOL_NAME,
+ NETWORK_PROTOCOL_VERSION,
+} from '@sentry/conventions/attributes';
import type { SpanAttributes } from '@sentry/core';
import { _INTERNAL_performanceTimeToSeconds } from '@sentry/core';
import { extractNetworkProtocol, msToSec } from './utils';
@@ -18,8 +35,8 @@ export function resourceTimingToSpanAttributes(resourceTiming: PerformanceResour
// valid for `nextHopProtocol` to be an empty string.
if (resourceTiming.nextHopProtocol != undefined) {
const { name, version } = extractNetworkProtocol(resourceTiming.nextHopProtocol);
- timingSpanData['network.protocol.version'] = version;
- timingSpanData['network.protocol.name'] = name;
+ timingSpanData[NETWORK_PROTOCOL_VERSION] = version;
+ timingSpanData[NETWORK_PROTOCOL_NAME] = name;
}
if (_INTERNAL_performanceTimeToSeconds(resourceTiming.startTime) === undefined) {
@@ -35,29 +52,29 @@ export function resourceTimingToSpanAttributes(resourceTiming: PerformanceResour
return dropUndefinedKeysFromObject({
...timingSpanData,
- 'http.request.redirect_start': getAbsoluteTime(resourceTiming.redirectStart),
- 'http.request.redirect_end': getAbsoluteTime(resourceTiming.redirectEnd),
+ [HTTP_REQUEST_REDIRECT_START]: getAbsoluteTime(resourceTiming.redirectStart),
+ [HTTP_REQUEST_REDIRECT_END]: getAbsoluteTime(resourceTiming.redirectEnd),
- 'http.request.worker_start': getAbsoluteTime(resourceTiming.workerStart),
+ [HTTP_REQUEST_WORKER_START]: getAbsoluteTime(resourceTiming.workerStart),
- 'http.request.fetch_start': getAbsoluteTime(resourceTiming.fetchStart),
+ [HTTP_REQUEST_FETCH_START]: getAbsoluteTime(resourceTiming.fetchStart),
- 'http.request.domain_lookup_start': getAbsoluteTime(resourceTiming.domainLookupStart),
- 'http.request.domain_lookup_end': getAbsoluteTime(resourceTiming.domainLookupEnd),
+ [HTTP_REQUEST_DOMAIN_LOOKUP_START]: getAbsoluteTime(resourceTiming.domainLookupStart),
+ [HTTP_REQUEST_DOMAIN_LOOKUP_END]: getAbsoluteTime(resourceTiming.domainLookupEnd),
- 'http.request.connect_start': getAbsoluteTime(resourceTiming.connectStart),
- 'http.request.secure_connection_start': getAbsoluteTime(resourceTiming.secureConnectionStart),
- 'http.request.connection_end': getAbsoluteTime(resourceTiming.connectEnd),
+ [HTTP_REQUEST_CONNECT_START]: getAbsoluteTime(resourceTiming.connectStart),
+ [HTTP_REQUEST_SECURE_CONNECTION_START]: getAbsoluteTime(resourceTiming.secureConnectionStart),
+ [HTTP_REQUEST_CONNECTION_END]: getAbsoluteTime(resourceTiming.connectEnd),
- 'http.request.request_start': getAbsoluteTime(resourceTiming.requestStart),
+ [HTTP_REQUEST_REQUEST_START]: getAbsoluteTime(resourceTiming.requestStart),
- 'http.request.response_start': getAbsoluteTime(resourceTiming.responseStart),
- 'http.request.response_end': getAbsoluteTime(resourceTiming.responseEnd),
+ [HTTP_REQUEST_RESPONSE_START]: getAbsoluteTime(resourceTiming.responseStart),
+ [HTTP_REQUEST_RESPONSE_END]: getAbsoluteTime(resourceTiming.responseEnd),
// For TTFB we actually want the relative time from timeOrigin to responseStart
// This way, TTFB always measures the "first page load" experience.
// see: https://web.dev/articles/ttfb#measure-resource-requests
- 'http.request.time_to_first_byte':
+ [HTTP_REQUEST_TIME_TO_FIRST_BYTE]:
resourceTiming.responseStart != null ? msToSec(resourceTiming.responseStart) : undefined,
});
}
diff --git a/packages/browser-utils/src/web-vitals/emitSpan.ts b/packages/browser-utils/src/web-vitals/emitSpan.ts
index 5e25931f4bf8..90b11673e1b6 100644
--- a/packages/browser-utils/src/web-vitals/emitSpan.ts
+++ b/packages/browser-utils/src/web-vitals/emitSpan.ts
@@ -11,6 +11,7 @@ import {
USER_AGENT_ORIGINAL,
SENTRY_OP,
SENTRY_ORIGIN,
+ SENTRY_PAGELOAD_SPAN_ID,
} from '@sentry/conventions/attributes';
import { WINDOW } from '../types';
import type { MetricNavigationType } from '../instrumentation/performanceObserver';
@@ -100,7 +101,7 @@ export function _emitWebVitalSpan(options: WebVitalSpanOptions): void {
if (parentSpan && spanToJSON(parentSpan).attributes[SENTRY_OP] === 'pageload') {
// for LCP and CLS, we collect the pageload span id as an attribute
- attributes['sentry.pageload.span_id'] = parentSpan.spanContext().spanId;
+ attributes[SENTRY_PAGELOAD_SPAN_ID] = parentSpan.spanContext().spanId;
}
if (softNavigationId != null) {
diff --git a/packages/browser-utils/src/web-vitals/spans.ts b/packages/browser-utils/src/web-vitals/spans.ts
index 3361254ffc1d..ba08eceafcb0 100644
--- a/packages/browser-utils/src/web-vitals/spans.ts
+++ b/packages/browser-utils/src/web-vitals/spans.ts
@@ -37,6 +37,12 @@ import {
BROWSER_WEB_VITAL_INP_TARGET,
UI_COMPONENT_NAME,
SENTRY_OP,
+ BROWSER_WEB_VITAL_LCP_ELEMENT,
+ BROWSER_WEB_VITAL_LCP_ID,
+ BROWSER_WEB_VITAL_LCP_LOAD_TIME,
+ BROWSER_WEB_VITAL_LCP_RENDER_TIME,
+ BROWSER_WEB_VITAL_LCP_SIZE,
+ BROWSER_WEB_VITAL_LCP_URL,
} from '@sentry/conventions/attributes';
import { _emitWebVitalSpan } from './emitSpan';
import { isValidLcpMetric } from './lcp';
@@ -214,16 +220,16 @@ export function _sendLcpSpan(
const attributes: SpanAttributes = {};
if (selector) {
- attributes['browser.web_vital.lcp.element'] = selector;
+ attributes[BROWSER_WEB_VITAL_LCP_ELEMENT] = selector;
}
if (componentName) {
attributes[UI_COMPONENT_NAME] = componentName;
}
- entry?.id && (attributes['browser.web_vital.lcp.id'] = entry.id);
- entry?.url && (attributes['browser.web_vital.lcp.url'] = filterCollectedUrl(entry.url, client));
- entry?.loadTime != null && (attributes['browser.web_vital.lcp.load_time'] = entry.loadTime);
- entry?.renderTime != null && (attributes['browser.web_vital.lcp.render_time'] = entry.renderTime);
- entry?.size != null && (attributes['browser.web_vital.lcp.size'] = entry.size);
+ entry?.id && (attributes[BROWSER_WEB_VITAL_LCP_ID] = entry.id);
+ entry?.url && (attributes[BROWSER_WEB_VITAL_LCP_URL] = filterCollectedUrl(entry.url, client));
+ entry?.loadTime != null && (attributes[BROWSER_WEB_VITAL_LCP_LOAD_TIME] = entry.loadTime);
+ entry?.renderTime != null && (attributes[BROWSER_WEB_VITAL_LCP_RENDER_TIME] = entry.renderTime);
+ entry?.size != null && (attributes[BROWSER_WEB_VITAL_LCP_SIZE] = entry.size);
_emitWebVitalSpan({
name,
diff --git a/packages/browser-utils/src/web-vitals/tracking.ts b/packages/browser-utils/src/web-vitals/tracking.ts
index c83fd40f02f0..ee2907cf3e92 100644
--- a/packages/browser-utils/src/web-vitals/tracking.ts
+++ b/packages/browser-utils/src/web-vitals/tracking.ts
@@ -1,6 +1,20 @@
import type { Client, Measurements, Span } from '@sentry/core';
import { browserPerformanceTimeOrigin, debug, filterCollectedUrl, setMeasurement, spanToJSON } from '@sentry/core';
-import { BROWSER_NAVIGATION_TYPE, SENTRY_OP } from '@sentry/conventions/attributes';
+import {
+ BROWSER_NAVIGATION_TYPE,
+ SENTRY_OP,
+ BROWSER_PERFORMANCE_NAVIGATION_ACTIVATION_START,
+ BROWSER_PERFORMANCE_TIME_ORIGIN,
+ BROWSER_WEB_VITAL_TTFB_REQUEST_TIME,
+ LCP_ELEMENT,
+ LCP_ID,
+ LCP_LOADTIME,
+ LCP_RENDERTIME,
+ LCP_SIZE,
+ LCP_URL,
+ PERFORMANCE_ACTIVATIONSTART,
+ PERFORMANCE_TIMEORIGIN,
+} from '@sentry/conventions/attributes';
import { DEBUG_BUILD } from '../debug-build';
import { htmlTreeAsString } from '../htmlTreeAsString';
import type { MetricNavigationType } from '../instrumentation/performanceObserver';
@@ -186,7 +200,7 @@ export function addWebVitalsToSpan(span: Span, options: AddWebVitalsToSpanOption
}
});
if (_measurements['ttfb.requestTime']) {
- setAttr('ttfb.requestTime', _measurements['ttfb.requestTime'].value, 'browser.web_vital.ttfb.request_time');
+ setAttr('ttfb.requestTime', _measurements['ttfb.requestTime'].value, BROWSER_WEB_VITAL_TTFB_REQUEST_TIME);
}
} else {
// If CLS is tracked as a span (span streaming), don't record CLS as a measurement
@@ -213,7 +227,8 @@ export function addWebVitalsToSpan(span: Span, options: AddWebVitalsToSpanOption
}
// Set timeOrigin which denotes the timestamp which to base the LCP/FCP/FP/TTFB measurements on
- span.setAttribute(spanStreamingEnabled ? 'browser.performance.time_origin' : 'performance.timeOrigin', timeOrigin);
+ // oxlint-disable-next-line typescript/no-deprecated -- Preserve the existing attribute name.
+ span.setAttribute(spanStreamingEnabled ? BROWSER_PERFORMANCE_TIME_ORIGIN : PERFORMANCE_TIMEORIGIN, timeOrigin);
// In prerendering scenarios, where a page might be prefetched and pre-rendered before the user clicks the link,
// the navigation starts earlier than when the user clicks it. Web Vitals should always be based on the
@@ -222,7 +237,8 @@ export function addWebVitalsToSpan(span: Span, options: AddWebVitalsToSpanOption
// This is user action is called "activation" and the time between navigation and activation is stored in
// the `activationStart` attribute of the "navigation" PerformanceEntry.
span.setAttribute(
- spanStreamingEnabled ? 'browser.performance.navigation.activation_start' : 'performance.activationStart',
+ // oxlint-disable-next-line typescript/no-deprecated -- Preserve the existing attribute name.
+ spanStreamingEnabled ? BROWSER_PERFORMANCE_NAVIGATION_ACTIVATION_START : PERFORMANCE_ACTIVATIONSTART,
getActivationStart(),
);
}
@@ -244,31 +260,37 @@ function _setWebVitalAttributes(span: Span, options: AddWebVitalsToSpanOptions):
// Capture Properties of the LCP element that contributes to the LCP.
if (_lcpEntry.element) {
- span.setAttribute('lcp.element', htmlTreeAsString(_lcpEntry.element));
+ // oxlint-disable-next-line typescript/no-deprecated -- Preserve the existing attribute name.
+ span.setAttribute(LCP_ELEMENT, htmlTreeAsString(_lcpEntry.element));
}
if (_lcpEntry.id) {
- span.setAttribute('lcp.id', _lcpEntry.id);
+ // oxlint-disable-next-line typescript/no-deprecated -- Preserve the existing attribute name.
+ span.setAttribute(LCP_ID, _lcpEntry.id);
}
if (_lcpEntry.url) {
// Trim URL to the first 200 characters.
- span.setAttribute('lcp.url', filterCollectedUrl(_lcpEntry.url.trim()).slice(0, 200));
+ // oxlint-disable-next-line typescript/no-deprecated -- Preserve the existing attribute name.
+ span.setAttribute(LCP_URL, filterCollectedUrl(_lcpEntry.url.trim()).slice(0, 200));
}
if (_lcpEntry.loadTime != null) {
// loadTime is the time of LCP that's related to receiving the LCP element response..
- span.setAttribute('lcp.loadTime', _lcpEntry.loadTime);
+ // oxlint-disable-next-line typescript/no-deprecated -- Preserve the existing attribute name.
+ span.setAttribute(LCP_LOADTIME, _lcpEntry.loadTime);
}
if (_lcpEntry.renderTime != null) {
// renderTime is loadTime + rendering time
// it's 0 if the LCP element is loaded from a 3rd party origin that doesn't send the
// `Timing-Allow-Origin` header.
- span.setAttribute('lcp.renderTime', _lcpEntry.renderTime);
+ // oxlint-disable-next-line typescript/no-deprecated -- Preserve the existing attribute name.
+ span.setAttribute(LCP_RENDERTIME, _lcpEntry.renderTime);
}
- span.setAttribute('lcp.size', _lcpEntry.size);
+ // oxlint-disable-next-line typescript/no-deprecated -- Preserve the existing attribute name.
+ span.setAttribute(LCP_SIZE, _lcpEntry.size);
}
// Only add CLS attributes if CLS is being recorded on the pageload span
diff --git a/packages/browser-utils/test/performance/longAnimationFrames.test.ts b/packages/browser-utils/test/performance/longAnimationFrames.test.ts
new file mode 100644
index 000000000000..7721f4387644
--- /dev/null
+++ b/packages/browser-utils/test/performance/longAnimationFrames.test.ts
@@ -0,0 +1,88 @@
+import {
+ BROWSER_SCRIPT_INVOKER,
+ BROWSER_SCRIPT_INVOKER_TYPE,
+ BROWSER_SCRIPT_SOURCE_CHAR_POSITION,
+ CODE_FILE_PATH,
+ CODE_FUNCTION_NAME,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
+} from '@sentry/conventions/attributes';
+import { UI_LONG_ANIMATION_FRAME } from '@sentry/conventions/op';
+import { getMainCarrier, getSpanDescendants, setCurrentClient, spanToJSON, startSpan } from '@sentry/core';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { startTrackingLongAnimationFrames } from '../../src/performance/entries';
+import { getDefaultClientOptions, TestClient } from '../utils/TestClient';
+
+function mockScript(invoker: string, duration: number, sourceFunctionName: string, sourceCharPosition: number) {
+ return {
+ invoker,
+ invokerType: 'event-listener',
+ sourceURL: 'https://example.com/assets/app.js',
+ sourceFunctionName,
+ sourceCharPosition,
+ duration,
+ };
+}
+
+describe('startTrackingLongAnimationFrames', () => {
+ let observerCallback: ((list: PerformanceObserverEntryList) => void) | undefined;
+
+ beforeEach(() => {
+ getMainCarrier().__SENTRY__ = undefined;
+ const client = new TestClient(getDefaultClientOptions({ tracesSampleRate: 1 }));
+ setCurrentClient(client);
+ client.init();
+
+ vi.stubGlobal(
+ 'PerformanceObserver',
+ class {
+ public constructor(callback: (list: PerformanceObserverEntryList) => void) {
+ observerCallback = callback;
+ }
+
+ public observe(): void {
+ // noop
+ }
+ },
+ );
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('attributes the frame to its longest script, not its first one', () => {
+ startTrackingLongAnimationFrames();
+
+ const uiSpans = startSpan({ name: 'pageload', op: 'pageload' }, pageloadSpan => {
+ observerCallback!({
+ getEntries: () => [
+ {
+ startTime: 1200,
+ duration: 140,
+ scripts: [
+ mockScript('DOMWindow.onmousedown', 8, 'captureElementFromEvent', 204910),
+ mockScript('BUTTON#checkout.onclick', 110, 'handleCheckoutClick', 512),
+ mockScript('DOCUMENT.onclick', 6, 'recordBreadcrumb', 1024),
+ ],
+ },
+ ],
+ } as unknown as PerformanceObserverEntryList);
+
+ return getSpanDescendants(pageloadSpan).filter(
+ span => spanToJSON(span).attributes[SENTRY_OP] === UI_LONG_ANIMATION_FRAME,
+ );
+ });
+
+ expect(uiSpans).toHaveLength(1);
+ expect(spanToJSON(uiSpans[0]!).attributes).toEqual({
+ [SENTRY_OP]: UI_LONG_ANIMATION_FRAME,
+ [SENTRY_ORIGIN]: 'auto.ui.browser.metrics',
+ [BROWSER_SCRIPT_INVOKER]: 'BUTTON#checkout.onclick',
+ [BROWSER_SCRIPT_INVOKER_TYPE]: 'event-listener',
+ [CODE_FILE_PATH]: 'https://example.com/assets/app.js',
+ [CODE_FUNCTION_NAME]: 'handleCheckoutClick',
+ [BROWSER_SCRIPT_SOURCE_CHAR_POSITION]: 512,
+ });
+ });
+});
diff --git a/packages/browser/package.json b/packages/browser/package.json
index 969364991310..799b7f71354a 100644
--- a/packages/browser/package.json
+++ b/packages/browser/package.json
@@ -39,7 +39,7 @@
},
"dependencies": {
"@sentry/browser-utils": "11.10.0",
- "@sentry/conventions": "^0.29.0",
+ "@sentry/conventions": "^0.30.0",
"@sentry/feedback": "11.10.0",
"@sentry/replay": "11.10.0",
"@sentry/replay-canvas": "11.10.0",
@@ -48,7 +48,7 @@
"devDependencies": {
"@sentry-internal/integration-shims": "11.10.0",
"@sentry-internal/rollup-utils": "11.10.0",
- "fake-indexeddb": "^6.2.5"
+ "fake-indexeddb": "^6.2.7"
},
"scripts": {
"build": "run-p build:transpile build:bundle build:types",
diff --git a/packages/browser/src/integrations/culturecontext.ts b/packages/browser/src/integrations/culturecontext.ts
index 294f52402371..5978878627da 100644
--- a/packages/browser/src/integrations/culturecontext.ts
+++ b/packages/browser/src/integrations/culturecontext.ts
@@ -1,3 +1,4 @@
+import { CULTURE_CALENDAR, CULTURE_LOCALE, CULTURE_TIMEZONE } from '@sentry/conventions/attributes';
import type { CultureContext, IntegrationFn } from '@sentry/core';
import { defineIntegration, safeSetSpanJSONAttributes } from '@sentry/core';
import { WINDOW } from '../helpers';
@@ -22,9 +23,9 @@ const _cultureContextIntegration = (() => {
if (culture) {
safeSetSpanJSONAttributes(span, {
- 'culture.locale': culture.locale,
- 'culture.timezone': culture.timezone,
- 'culture.calendar': culture.calendar,
+ [CULTURE_LOCALE]: culture.locale,
+ [CULTURE_TIMEZONE]: culture.timezone,
+ [CULTURE_CALENDAR]: culture.calendar,
});
}
},
diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts
index a2bfaaa0f508..327dee750092 100644
--- a/packages/browser/src/profiling/utils.ts
+++ b/packages/browser/src/profiling/utils.ts
@@ -1,3 +1,4 @@
+import { THREAD_ID, THREAD_NAME } from '@sentry/conventions/attributes';
/* eslint-disable max-lines */
import type { Client, ContinuousThreadCpuProfile, DebugImage, ProfileChunk, Span } from '@sentry/core';
import {
@@ -306,6 +307,6 @@ export function shouldProfileSession(options: BrowserOptions): boolean {
}
export function setThreadAttributes(span: Span): void {
- span.setAttribute('thread.id', PROFILER_THREAD_ID_STRING);
- span.setAttribute('thread.name', PROFILER_THREAD_NAME);
+ span.setAttribute(THREAD_ID, PROFILER_THREAD_ID_STRING);
+ span.setAttribute(THREAD_NAME, PROFILER_THREAD_NAME);
}
diff --git a/packages/browser/src/tracing/backgroundtab.ts b/packages/browser/src/tracing/backgroundtab.ts
index 5867a4e07997..939837825d15 100644
--- a/packages/browser/src/tracing/backgroundtab.ts
+++ b/packages/browser/src/tracing/backgroundtab.ts
@@ -1,7 +1,7 @@
import { debug, getActiveSpan, getRootSpan, SPAN_STATUS_ERROR, spanToJSON } from '@sentry/core';
import { DEBUG_BUILD } from '../debug-build';
import { WINDOW } from '../helpers';
-import { SENTRY_OP } from '@sentry/conventions/attributes';
+import { SENTRY_OP, SENTRY_CANCELLATION_REASON } from '@sentry/conventions/attributes';
/**
* Add a listener that cancels and finishes a transaction when the global
@@ -35,7 +35,7 @@ export function registerBackgroundTabDetection(): void {
rootSpan.setStatus({ code: SPAN_STATUS_ERROR, message: cancelledStatus });
}
- rootSpan.setAttribute('sentry.cancellation_reason', 'document.hidden');
+ rootSpan.setAttribute(SENTRY_CANCELLATION_REASON, 'document.hidden');
rootSpan.end();
}
});
diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts
index b02a5d5ebcca..e21b40a86f27 100644
--- a/packages/browser/src/tracing/browserTracingIntegration.ts
+++ b/packages/browser/src/tracing/browserTracingIntegration.ts
@@ -367,7 +367,7 @@ export const browserTracingIntegration = ((options: Partial=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
diff --git a/packages/bundler-plugins/src/core/build-plugin-manager.ts b/packages/bundler-plugins/src/core/build-plugin-manager.ts
index 242a77d73f0d..a529629e4413 100644
--- a/packages/bundler-plugins/src/core/build-plugin-manager.ts
+++ b/packages/bundler-plugins/src/core/build-plugin-manager.ts
@@ -4,6 +4,7 @@ import * as dotenv from 'dotenv';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
+import { escape as escapeGlob } from 'glob';
import { SentryCliAdapter } from './cli';
import type { NormalizedOptions } from './options-mapping';
import { normalizeUserOptions, validateOptions } from './options-mapping';
@@ -12,8 +13,15 @@ import { createLogger } from './logger';
import { allowedToSendTelemetry, createSentryInstance, safeFlushTelemetry } from './sentry/telemetry';
import type { Options, SentrySDKBuildFlags } from './types';
import { arrayify, getProjects, getTurborepoEnvPassthroughWarning, stripQueryAndHashFromPath } from './utils';
-import { defaultRewriteSourcesHook, prepareBundleForDebugIdUpload } from './debug-id-upload';
+import {
+ defaultRewriteSourcesHook,
+ prepareArtifactForUpload,
+ prepareBundleForDebugIdUpload,
+ writePreparedArtifact,
+} from './debug-id-upload';
import { globFiles } from './glob';
+import type { SourceMapDecision, SourceMapKind } from './sourcemap-settings';
+import { decideSourceMapHandling } from './sourcemap-settings';
import { LIB_VERSION } from './version';
// Module-level guard to prevent duplicate deploy records when multiple bundler plugin
@@ -26,6 +34,43 @@ export function _resetDeployedReleasesForTesting(): void {
_deployedReleases.clear();
}
+/**
+ * A temporary folder that build artifacts are written into straight from the bundler's output,
+ * before the bundler writes them to disk. The upload reads from this folder, so the bundler can
+ * drop the source map assets from its own output.
+ */
+export type UploadStaging = {
+ readonly dir: string;
+ /** Number of artifacts written so far. */
+ readonly count: number;
+ /**
+ * Absolute paths of the chunks staged so far (`mapDir` joined with the file name). The upload
+ * does not prepare these again from disk, where a stale map of an earlier build could sit
+ * next to them.
+ */
+ readonly stagedChunkPaths: ReadonlySet;
+ /**
+ * Stamps the debug ID into the chunk and its source map and writes both into the folder.
+ *
+ * `mapSource` is `undefined` when the chunk has no separate source map. `mapDir` is the directory
+ * the map would be written to and is passed to the `rewriteSources` hook.
+ */
+ stage(input: {
+ code: string;
+ mapSource: string | undefined;
+ mapDir: string;
+ fileName: string;
+ }): Promise<'staged' | 'skipped' | 'failed'>;
+ /** Removes the folder. Call this when no upload will consume it. */
+ discard(): Promise;
+ /**
+ * Keeps the folder and logs how to upload it by hand. Call this when the bundler already
+ * dropped the staged source maps from its output but the upload did not run. Removes the
+ * folder instead when nothing was staged.
+ */
+ keep(reason: string): Promise;
+};
+
export type SentryBuildPluginManager = {
/**
* A logger instance that takes the options passed to the build plugin manager into account. (for silencing and log level etc.)
@@ -80,7 +125,28 @@ export type SentryBuildPluginManager = {
/**
* Uploads sourcemaps using the "Debug ID" method. This function takes a list of build artifact paths that will be uploaded
*/
- uploadSourcemaps(buildArtifactPaths: string[], opts?: { prepareArtifacts?: boolean }): Promise;
+ uploadSourcemaps(
+ buildArtifactPaths: string[],
+ opts?: { prepareArtifacts?: boolean; staging?: UploadStaging },
+ ): Promise<{ uploaded: boolean }>;
+
+ /**
+ * Creates a staging folder that `uploadSourcemaps` uploads together with the build artifacts
+ * found on disk, in both the prepared and the direct (`prepareArtifacts: false`) mode.
+ */
+ createUploadStaging(): Promise;
+
+ sourceMaps: {
+ /**
+ * Whether a source map upload can run with the current options and environment.
+ */
+ uploadPossible: boolean;
+ /**
+ * Decides how the plugin treats the user's source map setting (see `decideSourceMapHandling`).
+ * Logs the decision's message once per message.
+ */
+ decide(kind: SourceMapKind): SourceMapDecision;
+ };
/**
* Will delete artifacts based on the passed `sourcemaps.filesToDeleteAfterUpload` option.
@@ -155,8 +221,22 @@ export function createSentryBuildPluginManager(
createRelease: async () => {
/* noop */
},
- uploadSourcemaps: async () => {
- /* noop */
+ uploadSourcemaps: async () => ({ uploaded: false }),
+ createUploadStaging: async () => ({
+ dir: '',
+ count: 0,
+ stagedChunkPaths: new Set(),
+ stage: async () => 'skipped' as const,
+ discard: async () => {
+ /* noop */
+ },
+ keep: async () => {
+ /* noop */
+ },
+ }),
+ sourceMaps: {
+ uploadPossible: false,
+ decide: () => ({ enableHidden: false, deleteAfterUpload: false }),
},
deleteArtifacts: async () => {
/* noop */
@@ -283,6 +363,9 @@ export function createSentryBuildPluginManager(
};
}
+ const sourceMapUploadPossible = getSourceMapUploadBlocker(options, isDevMode) === undefined;
+ const loggedSourceMapMessages = new Set();
+
/**
* Returns a Promise that resolves when all the currently active dependencies are freed again.
*
@@ -548,17 +631,19 @@ export function createSentryBuildPluginManager(
* @param buildArtifactPaths - The paths of the build artifacts to upload
* @param opts - Optional flags to control temp folder usage and preparation
*/
- async uploadSourcemaps(buildArtifactPaths: string[], opts?: { prepareArtifacts?: boolean }) {
+ async uploadSourcemaps(
+ buildArtifactPaths: string[],
+ opts?: { prepareArtifacts?: boolean; staging?: UploadStaging },
+ ): Promise<{ uploaded: boolean }> {
+ const staging = opts?.staging;
+
if (!canUploadSourceMaps(options, logger, isDevMode)) {
- return;
+ await staging?.discard();
+ return { uploaded: false };
}
- // Early exit if assets is explicitly set to an empty array
const assets = options.sourcemaps?.assets;
- if (Array.isArray(assets) && assets.length === 0) {
- logger.debug('Empty `sourcemaps.assets` option provided. Will not upload sourcemaps with debug ID.');
- return;
- }
+ let uploaded = false;
await startSpan(
// This is `forceTransaction`ed because this span is used in dashboards in the form of indexed transactions.
@@ -568,7 +653,7 @@ export function createSentryBuildPluginManager(
// If we're not using a temp folder, we must not prepare artifacts in-place (to avoid mutating user files)
const shouldPrepare = opts?.prepareArtifacts ?? true;
- let folderToCleanUp: string | undefined;
+ let folderToCleanUp: string | undefined = staging?.dir;
// It is possible that this writeBundle hook (which calls this function) is called multiple times in one build (for example when reusing the plugin, or when using build tooling like `@vitejs/plugin-legacy`)
// Therefore we need to actually register the execution of this hook as dependency on the sourcemap files.
@@ -589,6 +674,18 @@ export function createSentryBuildPluginManager(
pathsToUpload = buildArtifactPaths;
}
+ // Staged artifacts are already prepared, so the CLI can take the folder as it is.
+ if (staging && staging.count > 0) {
+ pathsToUpload = [...pathsToUpload, staging.dir];
+ }
+
+ if (pathsToUpload.length === 0) {
+ logger.warn(
+ "Didn't find any matching sources for debug ID upload. Please check the `sourcemaps.assets` option.",
+ );
+ return;
+ }
+
await startSpan({ name: 'upload', scope: sentryScope }, async () => {
const cliInstance = new SentryCliAdapter(options);
await cliInstance.uploadSourcemaps(
@@ -601,6 +698,7 @@ export function createSentryBuildPluginManager(
);
});
+ uploaded = true;
logger.info('Successfully uploaded source maps to Sentry');
} else {
// Prepare artifacts in temp folder before uploading
@@ -611,7 +709,7 @@ export function createSentryBuildPluginManager(
logger.debug(
'No `sourcemaps.assets` option provided, falling back to uploading detected build artifacts.',
);
- globAssets = buildArtifactPaths;
+ globAssets = buildArtifactPaths.map(asset => escapeGlob(asset.split(path.sep).join('/')));
}
const globResult = await startSpan({ name: 'glob', scope: sentryScope }, async () =>
@@ -619,24 +717,24 @@ export function createSentryBuildPluginManager(
);
const debugIdChunkFilePaths = globResult.filter(debugIdChunkFilePath => {
- return !!stripQueryAndHashFromPath(debugIdChunkFilePath).match(/\.(js|mjs|cjs)$/);
+ const filePath = stripQueryAndHashFromPath(debugIdChunkFilePath);
+ return !!filePath.match(/\.(js|mjs|cjs)$/) && !staging?.stagedChunkPaths.has(path.resolve(filePath));
});
// The order of the files output by glob() is not deterministic
// Ensure order within the files so that {debug-id}-{chunkIndex} coupling is consistent
debugIdChunkFilePaths.sort();
- if (debugIdChunkFilePaths.length === 0) {
+ const stagedCount = staging?.count ?? 0;
+
+ if (debugIdChunkFilePaths.length === 0 && stagedCount === 0) {
logger.warn(
"Didn't find any matching sources for debug ID upload. Please check the `sourcemaps.assets` option.",
);
} else {
- const tmpUploadFolder = await startSpan({ name: 'mkdtemp', scope: sentryScope }, async () => {
- return (
- process.env?.['SENTRY_TEST_OVERRIDE_TEMP_DIR'] ||
- (await fs.promises.mkdtemp(path.join(os.tmpdir(), 'sentry-bundler-plugin-upload-')))
- );
- });
+ const tmpUploadFolder =
+ staging?.dir ??
+ (await startSpan({ name: 'mkdtemp', scope: sentryScope }, () => createTempUploadFolder()));
folderToCleanUp = tmpUploadFolder;
// Prepare into temp folder, then upload
@@ -647,7 +745,7 @@ export function createSentryBuildPluginManager(
await prepareBundleForDebugIdUpload(
chunkFilePath,
tmpUploadFolder,
- chunkIndex,
+ stagedCount + chunkIndex,
logger,
options.sourcemaps?.rewriteSources ?? defaultRewriteSourcesHook,
options.sourcemaps?.resolveSourceMap,
@@ -684,7 +782,7 @@ export function createSentryBuildPluginManager(
// bundler, so warn instead of silently reporting success.
if (files.length === 0) {
logger.warn(
- `No source maps found for any of the ${debugIdChunkFilePaths.length} matched build ` +
+ `No source maps found for any of the ${debugIdChunkFilePaths.length + stagedCount} matched build ` +
'artifacts, so no source maps were uploaded to Sentry. This usually means source map ' +
'generation is not enabled in your bundler. Enable it so Sentry can un-minify your stack traces.',
);
@@ -703,11 +801,16 @@ export function createSentryBuildPluginManager(
// this must be in the method so that the "no sourcemaps"
// early return doesn't also log success.
+ uploaded = true;
logger.info('Successfully uploaded source maps to Sentry');
});
}
}
} catch (e) {
+ if (staging && folderToCleanUp) {
+ await staging.keep('Source map upload failed.');
+ folderToCleanUp = undefined;
+ }
sentryScope.captureException('Error in "debugIdUploadPlugin" writeBundle hook');
handleRecoverableError(e, false);
} finally {
@@ -734,6 +837,97 @@ export function createSentryBuildPluginManager(
}
},
);
+
+ return { uploaded };
+ },
+
+ async createUploadStaging(): Promise {
+ const dir = await createTempUploadFolder();
+ const rewriteSourcesHook = options.sourcemaps?.rewriteSources ?? defaultRewriteSourcesHook;
+ let count = 0;
+ let discarded = false;
+ const stagedChunkPaths = new Set();
+
+ const discard = async (): Promise => {
+ if (discarded) {
+ return;
+ }
+ discarded = true;
+
+ if (process.env['SENTRY_TEST_OVERRIDE_TEMP_DIR']) {
+ return;
+ }
+
+ try {
+ await fs.promises.rm(dir, { recursive: true, force: true });
+ } catch (e) {
+ logger.debug(`Failed to remove staging folder ${dir}:`, e);
+ }
+ };
+
+ return {
+ dir,
+ get count() {
+ return count;
+ },
+ stagedChunkPaths,
+ async stage(input) {
+ const result = prepareArtifactForUpload({
+ code: input.code,
+ mapSource: input.mapSource,
+ mapDir: input.mapDir,
+ rewriteSourcesHook,
+ });
+
+ if (!result.ok) {
+ logger.debug(`Not staging ${input.fileName} for upload: ${result.reason}`);
+ return 'skipped';
+ }
+
+ const index = count++;
+
+ try {
+ await writePreparedArtifact(dir, `${result.artifact.debugId}-${index}`, result.artifact);
+ stagedChunkPaths.add(path.resolve(input.mapDir, path.basename(input.fileName)));
+ return 'staged';
+ } catch (e) {
+ sentryScope.captureException('Error staging build artifact for upload');
+ // Throwing by default: the bundler drops the source map from its
+ // output once staged, so a lost copy would leave no map at all.
+ handleRecoverableError(e, true);
+ return 'failed';
+ }
+ },
+ discard,
+ async keep(reason) {
+ if (count === 0) {
+ await discard();
+ return;
+ }
+ if (discarded) {
+ return;
+ }
+ discarded = true;
+
+ // The bundler dropped the staged source maps from its output, so
+ // this folder is the only copy left.
+ logger.error(
+ `${reason} The source maps were kept in ${dir}. Upload them with \`sentry sourcemap upload ${dir}\`.`,
+ );
+ },
+ };
+ },
+
+ sourceMaps: {
+ uploadPossible: sourceMapUploadPossible,
+ decide(kind) {
+ const decision = decideSourceMapHandling(kind, options, sourceMapUploadPossible);
+ if (decision.log && !loggedSourceMapMessages.has(decision.log.message)) {
+ loggedSourceMapMessages.add(decision.log.message);
+ logger[decision.log.level](decision.log.message);
+ }
+ return decision;
+ },
},
/**
@@ -774,33 +968,64 @@ export function createSentryBuildPluginManager(
};
}
-function canUploadSourceMaps(options: NormalizedOptions, logger: Logger, isDevMode: boolean): boolean {
+async function createTempUploadFolder(): Promise {
+ return (
+ process.env['SENTRY_TEST_OVERRIDE_TEMP_DIR'] ||
+ (await fs.promises.mkdtemp(path.join(os.tmpdir(), 'sentry-bundler-plugin-upload-')))
+ );
+}
+
+type SourceMapUploadBlocker = 'disabled' | 'no-assets' | 'dev-mode' | 'no-auth-token' | 'no-org' | 'no-project';
+
+function getSourceMapUploadBlocker(options: NormalizedOptions, isDevMode: boolean): SourceMapUploadBlocker | undefined {
if (options.sourcemaps?.disable) {
- logger.debug('Source map upload was disabled. Will not upload sourcemaps using debug ID process.');
- return false;
+ return 'disabled';
+ }
+ if (Array.isArray(options.sourcemaps?.assets) && options.sourcemaps.assets.length === 0) {
+ return 'no-assets';
}
if (isDevMode) {
- logger.debug('Running in development mode. Will not upload sourcemaps.');
- return false;
+ return 'dev-mode';
}
if (!options.authToken) {
- logger.warn(
- `No auth token provided. Will not upload source maps. Please set the \`authToken\` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/${getTurborepoEnvPassthroughWarning('SENTRY_AUTH_TOKEN')}`,
- );
- return false;
+ return 'no-auth-token';
}
if (!options.org && !options.authToken.startsWith('sntrys_')) {
- logger.warn(
- `No org provided. Will not upload source maps. Please set the \`org\` option to your Sentry organization slug.${getTurborepoEnvPassthroughWarning('SENTRY_ORG')}`,
- );
- return false;
+ return 'no-org';
}
if (!getProjects(options.project)?.[0]) {
- logger.warn(
- `No project provided. Will not upload source maps. Please set the \`project\` option to your Sentry project slug.${getTurborepoEnvPassthroughWarning('SENTRY_PROJECT')}`,
- );
- return false;
+ return 'no-project';
}
+ return undefined;
+}
- return true;
+function canUploadSourceMaps(options: NormalizedOptions, logger: Logger, isDevMode: boolean): boolean {
+ switch (getSourceMapUploadBlocker(options, isDevMode)) {
+ case 'disabled':
+ logger.debug('Source map upload was disabled. Will not upload sourcemaps using debug ID process.');
+ return false;
+ case 'no-assets':
+ logger.debug('Empty `sourcemaps.assets` option provided. Will not upload sourcemaps with debug ID.');
+ return false;
+ case 'dev-mode':
+ logger.debug('Running in development mode. Will not upload sourcemaps.');
+ return false;
+ case 'no-auth-token':
+ logger.warn(
+ `No auth token provided. Will not upload source maps. Please set the \`authToken\` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/${getTurborepoEnvPassthroughWarning('SENTRY_AUTH_TOKEN')}`,
+ );
+ return false;
+ case 'no-org':
+ logger.warn(
+ `No org provided. Will not upload source maps. Please set the \`org\` option to your Sentry organization slug.${getTurborepoEnvPassthroughWarning('SENTRY_ORG')}`,
+ );
+ return false;
+ case 'no-project':
+ logger.warn(
+ `No project provided. Will not upload source maps. Please set the \`project\` option to your Sentry project slug.${getTurborepoEnvPassthroughWarning('SENTRY_PROJECT')}`,
+ );
+ return false;
+ default:
+ return true;
+ }
}
diff --git a/packages/bundler-plugins/src/core/debug-id-upload.ts b/packages/bundler-plugins/src/core/debug-id-upload.ts
index 8041e8c99d0f..cf402b5f476f 100644
--- a/packages/bundler-plugins/src/core/debug-id-upload.ts
+++ b/packages/bundler-plugins/src/core/debug-id-upload.ts
@@ -2,8 +2,7 @@ import fs from 'fs';
import path from 'path';
import * as url from 'url';
import * as util from 'util';
-import { promisify } from 'util';
-import type { SentryBuildPluginManager } from './build-plugin-manager';
+import type { SentryBuildPluginManager, UploadStaging } from './build-plugin-manager';
import type { Logger } from './logger';
import type { ResolveSourceMapHook, RewriteSourcesHook } from './types';
import { stripQueryAndHashFromPath } from './utils';
@@ -13,11 +12,11 @@ interface DebugIdUploadPluginOptions {
}
export function createDebugIdUploadFunction({ sentryBuildPluginManager }: DebugIdUploadPluginOptions) {
- return async (buildArtifactPaths: string[]) => {
+ return async (buildArtifactPaths: string[], opts?: { staging?: UploadStaging }) => {
// Webpack and perhaps other bundlers allow you to append query strings to
// filenames for cache busting purposes. We should strip these before upload.
const cleanedPaths = buildArtifactPaths.map(stripQueryAndHashFromPath);
- await sentryBuildPluginManager.uploadSourcemaps(cleanedPaths);
+ return sentryBuildPluginManager.uploadSourcemaps(cleanedPaths, opts);
};
}
@@ -29,26 +28,21 @@ export async function prepareBundleForDebugIdUpload(
rewriteSourcesHook: RewriteSourcesHook,
resolveSourceMapHook: ResolveSourceMapHook | undefined,
): Promise {
- let bundleContent;
+ let bundleContent: string;
try {
- bundleContent = await promisify(fs.readFile)(bundleFilePath, 'utf8');
+ bundleContent = await fs.promises.readFile(bundleFilePath, 'utf8');
} catch (e) {
logger.error(`Could not read bundle to determine debug ID and source map: ${bundleFilePath}`, e);
return;
}
- const debugId = determineDebugIdFromBundleSource(bundleContent);
- if (debugId === undefined) {
+ if (determineDebugIdFromBundleSource(bundleContent) === undefined) {
logger.debug(
`Could not determine debug ID from bundle. This can happen if you did not clean your output folder before installing the Sentry plugin. File will not be source mapped: ${bundleFilePath}`,
);
return;
}
- const uniqueUploadName = `${debugId}-${chunkIndex}`;
-
- bundleContent = addDebugIdToBundleSource(bundleContent, debugId);
-
const sourceMapPath = await determineSourceMapPathFromBundle(
bundleFilePath,
bundleContent,
@@ -56,35 +50,114 @@ export async function prepareBundleForDebugIdUpload(
resolveSourceMapHook,
);
- // A chunk with a debug ID but no resolvable source map
- // (e.g. framework-generated stub chunks that never emit one) can't be
- // symbolicated, so uploading its minified source alone accomplishes
- // nothing and only makes the uploader warn about a missing source map ref.
- // Skip it, unless the source map is inlined into the bundle, in which case
- // the source file carries the map itself and must still be uploaded.
- if (!sourceMapPath && !bundleHasInlineSourceMap(bundleContent)) {
- logger.debug(`Not uploading bundle without a source map: ${bundleFilePath}`);
+ let sourceMapSource: string | undefined;
+ if (sourceMapPath) {
+ try {
+ sourceMapSource = await fs.promises.readFile(sourceMapPath, 'utf8');
+ } catch (e) {
+ logger.error(`Failed to read source map for debug ID upload: ${sourceMapPath}`, e);
+ return;
+ }
+ }
+
+ const result = prepareArtifactForUpload({
+ code: bundleContent,
+ mapSource: sourceMapSource,
+ mapDir: path.dirname(sourceMapPath ?? bundleFilePath),
+ rewriteSourcesHook,
+ });
+
+ if (!result.ok) {
+ if (result.reason === 'invalid-source-map') {
+ logger.error(`Failed to parse source map for debug ID upload: ${sourceMapPath}`);
+ } else {
+ // A chunk with a debug ID but no resolvable source map
+ // (e.g. framework-generated stub chunks that never emit one) can't be
+ // symbolicated, so uploading its minified source alone accomplishes
+ // nothing and only makes the uploader warn about a missing source map ref.
+ logger.debug(`Not uploading bundle without a source map: ${bundleFilePath}`);
+ }
return;
}
- const writeSourceFilePromise = fs.promises.writeFile(
- path.join(uploadFolder, `${uniqueUploadName}.js`),
- bundleContent,
- 'utf-8',
- );
+ await writePreparedArtifact(uploadFolder, `${result.artifact.debugId}-${chunkIndex}`, result.artifact);
+}
+
+export type PreparedUploadArtifact = {
+ debugId: string;
+ bundleSource: string;
+ /** `undefined` when the bundle has no separate source map (i.e. the map is inlined). */
+ sourceMapSource: string | undefined;
+};
+
+export type PrepareArtifactResult =
+ | { ok: true; artifact: PreparedUploadArtifact }
+ | { ok: false; reason: 'no-debug-id' | 'no-source-map' | 'invalid-source-map' };
- const writeSourceMapFilePromise = sourceMapPath
- ? prepareSourceMapForDebugIdUpload(
- sourceMapPath,
- path.join(uploadFolder, `${uniqueUploadName}.js.map`),
- debugId,
- rewriteSourcesHook,
- logger,
- )
- : Promise.resolve();
-
- await writeSourceFilePromise;
- await writeSourceMapFilePromise;
+/**
+ * Prepares a bundle and its source map for the debug ID upload: stamps the debug ID into both
+ * and runs the `rewriteSources` hook over the map's `sources`.
+ *
+ * Pass `mapSource: undefined` when the bundle has no separate source map. A bundle with an
+ * inlined map is still prepared; a bundle without any map is skipped because it cannot be
+ * symbolicated.
+ */
+export function prepareArtifactForUpload(input: {
+ code: string;
+ mapSource: string | undefined;
+ /** Directory the source map lives in, passed to the `rewriteSources` hook. */
+ mapDir: string;
+ rewriteSourcesHook: RewriteSourcesHook;
+}): PrepareArtifactResult {
+ const debugId = determineDebugIdFromBundleSource(input.code);
+ if (debugId === undefined) {
+ return { ok: false, reason: 'no-debug-id' };
+ }
+
+ const bundleSource = addDebugIdToBundleSource(input.code, debugId);
+
+ if (input.mapSource === undefined) {
+ if (!bundleHasInlineSourceMap(input.code)) {
+ return { ok: false, reason: 'no-source-map' };
+ }
+ return { ok: true, artifact: { debugId, bundleSource, sourceMapSource: undefined } };
+ }
+
+ const map = parseSourceMap(input.mapSource);
+ if (!map) {
+ return { ok: false, reason: 'invalid-source-map' };
+ }
+
+ setDebugIdOnSourceMap(map, debugId);
+
+ if (Array.isArray(map['sources'])) {
+ map['sources'] = map['sources'].map((source: string) =>
+ input.rewriteSourcesHook(source, map, { mapDir: input.mapDir }),
+ );
+ }
+
+ return { ok: true, artifact: { debugId, bundleSource, sourceMapSource: JSON.stringify(map) } };
+}
+
+/**
+ * Writes a prepared artifact as `.js` (and `.js.map`) into the upload folder.
+ */
+export async function writePreparedArtifact(
+ uploadFolder: string,
+ uniqueUploadName: string,
+ artifact: PreparedUploadArtifact,
+): Promise {
+ const writes = [
+ fs.promises.writeFile(path.join(uploadFolder, `${uniqueUploadName}.js`), artifact.bundleSource, 'utf-8'),
+ ];
+
+ if (artifact.sourceMapSource !== undefined) {
+ writes.push(
+ fs.promises.writeFile(path.join(uploadFolder, `${uniqueUploadName}.js.map`), artifact.sourceMapSource, 'utf-8'),
+ );
+ }
+
+ await Promise.all(writes);
}
/**
@@ -321,50 +394,6 @@ export async function determineSourceMapPathFromBundle(
return undefined;
}
-/**
- * Reads a source map, injects debug ID fields, and writes the source map to the target path.
- */
-async function prepareSourceMapForDebugIdUpload(
- sourceMapPath: string,
- targetPath: string,
- debugId: string,
- rewriteSourcesHook: RewriteSourcesHook,
- logger: Logger,
-): Promise {
- let sourceMapFileContent: string;
- try {
- sourceMapFileContent = await util.promisify(fs.readFile)(sourceMapPath, {
- encoding: 'utf8',
- });
- } catch (e) {
- logger.error(`Failed to read source map for debug ID upload: ${sourceMapPath}`, e);
- return;
- }
-
- let map: Record;
- try {
- map = JSON.parse(sourceMapFileContent) as { sources: unknown; [key: string]: unknown };
- setDebugIdOnSourceMap(map, debugId);
- } catch {
- logger.error(`Failed to parse source map for debug ID upload: ${sourceMapPath}`);
- return;
- }
-
- if (map['sources'] && Array.isArray(map['sources'])) {
- const mapDir = path.dirname(sourceMapPath);
- map['sources'] = map['sources'].map((source: string) => rewriteSourcesHook(source, map, { mapDir }));
- }
-
- try {
- await util.promisify(fs.writeFile)(targetPath, JSON.stringify(map), {
- encoding: 'utf8',
- });
- } catch (e) {
- logger.error(`Failed to prepare source map for debug ID upload: ${sourceMapPath}`, e);
- return;
- }
-}
-
const PROTOCOL_REGEX = /^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//;
export function defaultRewriteSourcesHook(source: string): string {
if (source.match(PROTOCOL_REGEX)) {
diff --git a/packages/bundler-plugins/src/core/index.ts b/packages/bundler-plugins/src/core/index.ts
index 4edc5ebbed91..e46caaaec203 100644
--- a/packages/bundler-plugins/src/core/index.ts
+++ b/packages/bundler-plugins/src/core/index.ts
@@ -110,4 +110,7 @@ export {
generateModuleMetadataInjectorCode,
} from './utils';
export { createSentryBuildPluginManager } from './build-plugin-manager';
+export type { SentryBuildPluginManager, UploadStaging } from './build-plugin-manager';
export { createDebugIdUploadFunction, addDebugIdToEmittedArtifacts, stampDebugId } from './debug-id-upload';
+export { decideSourceMapHandling } from './sourcemap-settings';
+export type { SourceMapDecision, SourceMapKind } from './sourcemap-settings';
diff --git a/packages/bundler-plugins/src/core/options-mapping.ts b/packages/bundler-plugins/src/core/options-mapping.ts
index 4847b11b0415..c190d3efc5cc 100644
--- a/packages/bundler-plugins/src/core/options-mapping.ts
+++ b/packages/bundler-plugins/src/core/options-mapping.ts
@@ -30,6 +30,7 @@ export type NormalizedOptions = {
rewriteSources?: RewriteSourcesHook;
resolveSourceMap?: ResolveSourceMapHook;
filesToDeleteAfterUpload?: string | string[] | Promise;
+ deleteSourcemapsAfterUpload?: boolean;
}
| undefined;
release: {
@@ -214,6 +215,13 @@ export function validateOptions(options: NormalizedOptions, logger: Logger): boo
return false;
}
+ if (options.sourcemaps?.deleteSourcemapsAfterUpload && options.sourcemaps.disable) {
+ logger.warn(
+ 'The `sourcemaps.deleteSourcemapsAfterUpload` option is set but source map upload is disabled via `sourcemaps.disable`.',
+ 'Source maps will not be deleted.',
+ );
+ }
+
if (options.project && Array.isArray(options.project)) {
if (options.project.length === 0) {
logger.error(
diff --git a/packages/bundler-plugins/src/core/sentry/telemetry.ts b/packages/bundler-plugins/src/core/sentry/telemetry.ts
index 72ea587bfef8..c9da77637d90 100644
--- a/packages/bundler-plugins/src/core/sentry/telemetry.ts
+++ b/packages/bundler-plugins/src/core/sentry/telemetry.ts
@@ -106,6 +106,10 @@ export function setTelemetryDataOnScope(
scope.setTag('custom-error-handler', !!errorHandler);
scope.setTag('sourcemaps-assets', !!sourcemaps?.assets);
scope.setTag('delete-after-upload', !!sourcemaps?.filesToDeleteAfterUpload);
+ scope.setTag(
+ 'delete-sourcemaps-after-upload',
+ sourcemaps?.deleteSourcemapsAfterUpload === undefined ? 'auto' : String(sourcemaps.deleteSourcemapsAfterUpload),
+ );
scope.setTag('sourcemaps-disabled', !!sourcemaps?.disable);
scope.setTag('react-annotate', !!reactComponentAnnotation?.enabled);
diff --git a/packages/bundler-plugins/src/core/sourcemap-settings.ts b/packages/bundler-plugins/src/core/sourcemap-settings.ts
new file mode 100644
index 000000000000..0f5a3b3d12fc
--- /dev/null
+++ b/packages/bundler-plugins/src/core/sourcemap-settings.ts
@@ -0,0 +1,103 @@
+import type { NormalizedOptions } from './options-mapping';
+
+/**
+ * The user's source map setting in the bundler, reduced to what matters for upload and deletion.
+ *
+ * - `unset`: the user did not configure source maps
+ * - `off`: source map generation is turned off
+ * - `visible`: separate map files with a `sourceMappingURL` comment
+ * - `hidden`: separate map files without the comment
+ * - `inline`: the map is embedded into the bundle
+ */
+export type SourceMapKind = 'unset' | 'off' | 'visible' | 'hidden' | 'inline';
+
+export type SourceMapDecision = {
+ /** Switch the bundler to hidden source maps. */
+ enableHidden: boolean;
+ /** Remove the source maps from the build output once they are uploaded. */
+ deleteAfterUpload: boolean;
+ log?: { level: 'info' | 'warn'; message: string };
+};
+
+const NO_CHANGE: SourceMapDecision = { enableHidden: false, deleteAfterUpload: false };
+
+/**
+ * Decides whether the plugin turns hidden source maps on and whether it removes the maps from the
+ * build output after the upload. The plugin only turns maps on when an upload can run, and only
+ * removes maps it turned on itself unless `deleteSourcemapsAfterUpload` says otherwise.
+ */
+export function decideSourceMapHandling(
+ kind: SourceMapKind,
+ options: NormalizedOptions,
+ uploadPossible: boolean,
+): SourceMapDecision {
+ const deleteOption = options.sourcemaps?.deleteSourcemapsAfterUpload;
+
+ if (!uploadPossible || options.sourcemaps?.disable) {
+ return NO_CHANGE;
+ }
+
+ switch (kind) {
+ case 'off':
+ return {
+ ...NO_CHANGE,
+ log: {
+ level: 'warn',
+ message:
+ 'Source map generation is turned off in your bundler, so Sentry cannot un-minify your stack traces. ' +
+ 'Remove the setting to let the plugin generate hidden source maps, upload them to Sentry and remove them from the build output.',
+ },
+ };
+
+ case 'inline':
+ return {
+ ...NO_CHANGE,
+ log: {
+ level: 'warn',
+ message:
+ 'Source maps are inlined into your bundles. They are uploaded to Sentry, but inline source maps ship your source code to users and cannot be removed after the upload. ' +
+ 'Remove the setting to let the plugin generate hidden source maps instead.',
+ },
+ };
+
+ case 'unset':
+ if (deleteOption === false) {
+ return {
+ enableHidden: true,
+ deleteAfterUpload: false,
+ log: {
+ level: 'info',
+ message: 'Enabling hidden source maps. They are uploaded to Sentry and kept in the build output.',
+ },
+ };
+ }
+ return {
+ enableHidden: true,
+ deleteAfterUpload: true,
+ log: {
+ level: 'info',
+ message:
+ 'Enabling hidden source maps. They are uploaded to Sentry and removed from the build output. ' +
+ 'Set `sourcemaps.deleteSourcemapsAfterUpload: false` to keep them.',
+ },
+ };
+
+ case 'hidden':
+ return { enableHidden: false, deleteAfterUpload: deleteOption === true };
+
+ case 'visible':
+ if (deleteOption !== true) {
+ return NO_CHANGE;
+ }
+ return {
+ enableHidden: true,
+ deleteAfterUpload: true,
+ log: {
+ level: 'info',
+ message:
+ 'Switching to hidden source maps because `sourcemaps.deleteSourcemapsAfterUpload` is set. ' +
+ 'Without the `sourceMappingURL` comment browsers do not request the removed map files.',
+ },
+ };
+ }
+}
diff --git a/packages/bundler-plugins/src/core/types.ts b/packages/bundler-plugins/src/core/types.ts
index bd3761c12dca..5712cf3cfa5b 100644
--- a/packages/bundler-plugins/src/core/types.ts
+++ b/packages/bundler-plugins/src/core/types.ts
@@ -192,6 +192,18 @@ export interface Options {
* Use the `debug` option to print information about which files end up being deleted.
*/
filesToDeleteAfterUpload?: string | string[] | Promise;
+
+ /**
+ * Whether source maps are removed from the build output after they are uploaded to Sentry.
+ *
+ * When unset, the plugin removes only the source maps it turned on itself, that is, when the bundler's source map
+ * setting was not set. `true` also removes source maps you enabled yourself. `false` never removes them; the plugin
+ * still turns on hidden source maps when the setting is not set, so they can be uploaded.
+ *
+ * With Rollup, Vite and webpack the removed source maps are never written to the output directory. With esbuild
+ * they are deleted after the upload.
+ */
+ deleteSourcemapsAfterUpload?: boolean;
};
/**
diff --git a/packages/bundler-plugins/src/esbuild/index.ts b/packages/bundler-plugins/src/esbuild/index.ts
index fc17904624be..b369bc67a3ec 100644
--- a/packages/bundler-plugins/src/esbuild/index.ts
+++ b/packages/bundler-plugins/src/esbuild/index.ts
@@ -67,6 +67,7 @@ interface EsbuildPluginBuild {
options: { filter: RegExp },
callback: (args: EsbuildOnResolveArgs) => EsbuildOnResolveResult | undefined,
) => void;
+ onStart: (callback: () => void) => void;
onEnd: (callback: (result: EsbuildOnEndArgs) => void | Promise) => void;
}
@@ -156,7 +157,7 @@ export function sentryEsbuildPlugin(userOptions: Options = {}): any {
return {
name: pluginName,
- setup({ initialOptions, onLoad, onResolve, onEnd }: EsbuildPluginBuild) {
+ setup({ initialOptions, onLoad, onResolve, onStart, onEnd }: EsbuildPluginBuild) {
// Release and/or metadata injection
if (!staticInjectionCode.isEmpty()) {
const virtualInjectionFilePath = path.resolve('_sentry-injection-stub');
@@ -194,8 +195,9 @@ export function sentryEsbuildPlugin(userOptions: Options = {}): any {
// Debug ID injection - requires per-entry-point unique IDs
if (sourcemapsEnabled) {
- // Clear state from previous builds (important for watch mode and test suites)
- debugIdWrappedPaths.clear();
+ // `setup` runs once per context, `onStart` once per build, so a
+ // rebuild wraps the entry points again.
+ onStart(() => debugIdWrappedPaths.clear());
if (!initialOptions.bundle) {
logger.warn(
diff --git a/packages/bundler-plugins/test/core/component-annotation-parser-unavailable.test.ts b/packages/bundler-plugins/test/core/component-annotation-parser-unavailable.test.ts
index fc60b926ddf9..9d845f28fc49 100644
--- a/packages/bundler-plugins/test/core/component-annotation-parser-unavailable.test.ts
+++ b/packages/bundler-plugins/test/core/component-annotation-parser-unavailable.test.ts
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type * as ComponentAnnotateHooks from '../../src/core/component-annotate-hooks';
import type * as ComponentAnnotation from '../../src/core/component-annotation-oxc';
vi.mock('../../src/core/component-annotation-oxc', async importOriginal => {
@@ -12,8 +13,11 @@ vi.mock('../../src/core/component-annotation-oxc', async importOriginal => {
const MESSAGE = 'Could not load `oxc-parser` for this platform. React components will not be annotated.';
describe('createComponentNameAnnotateHooks without a parser', () => {
- beforeEach(() => {
+ let hooks: typeof ComponentAnnotateHooks;
+
+ beforeEach(async () => {
vi.resetModules();
+ hooks = await import('../../src/core/component-annotate-hooks');
});
afterEach(() => {
@@ -21,9 +25,8 @@ describe('createComponentNameAnnotateHooks without a parser', () => {
});
it('leaves files unchanged and warns once through the logger', async () => {
- const { createComponentNameAnnotateHooks } = await import('../../src/core');
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
- const { transform } = createComponentNameAnnotateHooks([], false, { logger });
+ const { transform } = hooks.createComponentNameAnnotateHooks([], false, { logger });
await expect(transform('export const App = () =>
;', '/src/app.jsx')).resolves.toBeNull();
await expect(transform('export const Other = () => ;', '/src/other.jsx')).resolves.toBeNull();
@@ -33,11 +36,10 @@ describe('createComponentNameAnnotateHooks without a parser', () => {
});
it('warns once through the console across hooks created without a logger', async () => {
- const { createComponentNameAnnotateHooks } = await import('../../src/core');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
for (const id of ['/src/app.jsx', '/src/other.jsx']) {
- const { transform } = createComponentNameAnnotateHooks([], false);
+ const { transform } = hooks.createComponentNameAnnotateHooks([], false);
await expect(transform('export const App = () =>
;', id)).resolves.toBeNull();
}
diff --git a/packages/bundler-plugins/test/core/debug-id-upload.test.ts b/packages/bundler-plugins/test/core/debug-id-upload.test.ts
index ddeeff073fc3..4c6690e3b42d 100644
--- a/packages/bundler-plugins/test/core/debug-id-upload.test.ts
+++ b/packages/bundler-plugins/test/core/debug-id-upload.test.ts
@@ -4,8 +4,10 @@ import * as path from 'path';
import * as os from 'os';
import {
addDebugIdToEmittedArtifacts,
+ prepareArtifactForUpload,
prepareBundleForDebugIdUpload,
stampDebugId,
+ writePreparedArtifact,
} from '../../src/core/debug-id-upload';
import type { RewriteSourcesHook } from '../../src/core/types';
import type { Logger } from '../../src/core';
@@ -74,6 +76,19 @@ describe('prepareBundleForDebugIdUpload', () => {
const noopRewriteHook: RewriteSourcesHook = source => source;
+ it('lets a failed write of the upload artifact propagate', async () => {
+ const bundlePath = path.join(tmpDir, 'bundle.js');
+ fs.writeFileSync(
+ bundlePath,
+ `"use strict";\n${debugIdSnippet('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')}\n//# sourceMappingURL=bundle.js.map`,
+ );
+ fs.writeFileSync(`${bundlePath}.map`, JSON.stringify({ version: 3, sources: ['a.ts'], mappings: 'AAAA' }));
+
+ await expect(
+ prepareBundleForDebugIdUpload(bundlePath, path.join(tmpDir, 'missing'), 0, makeLogger(), s => s, undefined),
+ ).rejects.toThrow();
+ });
+
it('does not write an upload artifact for a chunk that has no source map', async () => {
const bundleDir = path.join(tmpDir, 'src');
const uploadDir = path.join(tmpDir, 'upload');
@@ -112,6 +127,117 @@ describe('prepareBundleForDebugIdUpload', () => {
});
});
+describe('prepareArtifactForUpload', () => {
+ const debugId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
+ const noopRewriteHook: RewriteSourcesHook = source => source;
+ const map = JSON.stringify({ version: 3, sources: ['../src/a.ts'], mappings: 'AAAA' });
+
+ it('stamps the debug ID into the bundle and the map and rewrites the sources', () => {
+ const rewriteHook: RewriteSourcesHook = (source, _map, context) => `${context?.mapDir}|${source}`;
+
+ const result = prepareArtifactForUpload({
+ code: `code();\n${debugIdSnippet(debugId)}`,
+ mapSource: map,
+ mapDir: '/out/assets',
+ rewriteSourcesHook: rewriteHook,
+ });
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) {
+ return;
+ }
+ expect(result.artifact.debugId).toBe(debugId);
+ expect(result.artifact.bundleSource).toBe(`code();\n${debugIdSnippet(debugId)}\n//# debugId=${debugId}`);
+ expect(JSON.parse(result.artifact.sourceMapSource!)).toEqual({
+ version: 3,
+ sources: ['/out/assets|../src/a.ts'],
+ mappings: 'AAAA',
+ debug_id: debugId,
+ debugId,
+ });
+ });
+
+ it('replaces an existing spec comment', () => {
+ const result = prepareArtifactForUpload({
+ code: `${debugIdSnippet(debugId)}\n//# debugId=00000000-0000-0000-0000-000000000000`,
+ mapSource: map,
+ mapDir: '/out',
+ rewriteSourcesHook: noopRewriteHook,
+ });
+
+ expect(result.ok && result.artifact.bundleSource).toBe(`${debugIdSnippet(debugId)}\n//# debugId=${debugId}`);
+ });
+
+ it('prepares a bundle with an inlined map without a separate map', () => {
+ const result = prepareArtifactForUpload({
+ code: `${debugIdSnippet(debugId)}\n//# sourceMappingURL=data:application/json;base64,e30=`,
+ mapSource: undefined,
+ mapDir: '/out',
+ rewriteSourcesHook: noopRewriteHook,
+ });
+
+ expect(result.ok && result.artifact.sourceMapSource).toBeUndefined();
+ });
+
+ it('reports a bundle without a debug ID', () => {
+ expect(
+ prepareArtifactForUpload({
+ code: 'code();',
+ mapSource: map,
+ mapDir: '/out',
+ rewriteSourcesHook: noopRewriteHook,
+ }),
+ ).toEqual({ ok: false, reason: 'no-debug-id' });
+ });
+
+ it('reports a bundle without any source map', () => {
+ expect(
+ prepareArtifactForUpload({
+ code: debugIdSnippet(debugId),
+ mapSource: undefined,
+ mapDir: '/out',
+ rewriteSourcesHook: noopRewriteHook,
+ }),
+ ).toEqual({ ok: false, reason: 'no-source-map' });
+ });
+
+ it('reports an invalid source map', () => {
+ expect(
+ prepareArtifactForUpload({
+ code: debugIdSnippet(debugId),
+ mapSource: '{not json',
+ mapDir: '/out',
+ rewriteSourcesHook: noopRewriteHook,
+ }),
+ ).toEqual({ ok: false, reason: 'invalid-source-map' });
+ });
+});
+
+describe('writePreparedArtifact', () => {
+ let tmpDir: string;
+
+ beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-test-'));
+ });
+
+ afterEach(() => {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ it('writes the bundle and the map', async () => {
+ await writePreparedArtifact(tmpDir, 'abc-0', { debugId: 'abc', bundleSource: 'js', sourceMapSource: '{}' });
+
+ expect(fs.readFileSync(path.join(tmpDir, 'abc-0.js'), 'utf8')).toBe('js');
+ expect(fs.readFileSync(path.join(tmpDir, 'abc-0.js.map'), 'utf8')).toBe('{}');
+ });
+
+ it('writes only the bundle when there is no separate map', async () => {
+ await writePreparedArtifact(tmpDir, 'abc-0', { debugId: 'abc', bundleSource: 'js', sourceMapSource: undefined });
+
+ expect(fs.readdirSync(tmpDir)).toEqual(['abc-0.js']);
+ });
+});
+
describe('stampDebugId', () => {
const debugId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
const bundleSource = `"use strict";\n${debugIdSnippet(debugId)}\n//# sourceMappingURL=bundle.js.map`;
diff --git a/packages/bundler-plugins/test/core/glob.test.ts b/packages/bundler-plugins/test/core/glob.test.ts
index 74583dd01c4e..ffbc776fecc6 100644
--- a/packages/bundler-plugins/test/core/glob.test.ts
+++ b/packages/bundler-plugins/test/core/glob.test.ts
@@ -140,6 +140,15 @@ describe('globFiles', () => {
expect(result).toEqual([]);
});
+ it.skipIf(process.platform === 'win32')('matches files with a query or hash suffix', async () => {
+ const files = await Promise.all([touch('a.js?v=1'), touch('a.js.map#hash'), touch('b.mjs?v=1#hash')]);
+ await touch('c.css?v=1');
+
+ const result = await globFiles(JS_AND_MAP_PATTERNS, { root: tmpDir });
+ result.sort();
+ expect(result).toEqual(files.sort());
+ });
+
it('works in nested subdirectories', async () => {
const files = await Promise.all([touch('src', 'deep', 'a.js'), touch('src', 'deep', 'a.js.map')]);
diff --git a/packages/bundler-plugins/test/core/option-mappings.test.ts b/packages/bundler-plugins/test/core/option-mappings.test.ts
index 341aad2f21e6..1c1bbbe42b82 100644
--- a/packages/bundler-plugins/test/core/option-mappings.test.ts
+++ b/packages/bundler-plugins/test/core/option-mappings.test.ts
@@ -351,6 +351,25 @@ describe('validateOptions', () => {
expect(mockedLogger.error).not.toHaveBeenCalled();
});
+ it('warns when `deleteSourcemapsAfterUpload` is set together with `sourcemaps.disable`', () => {
+ const options = normalizeUserOptions({
+ sourcemaps: { disable: 'disable-upload', deleteSourcemapsAfterUpload: true },
+ });
+
+ expect(validateOptions(options, mockedLogger)).toBe(true);
+ expect(mockedLogger.warn).toHaveBeenCalledWith(
+ expect.stringContaining('deleteSourcemapsAfterUpload'),
+ expect.any(String),
+ );
+ });
+
+ it('does not warn when `deleteSourcemapsAfterUpload` is set and uploads are enabled', () => {
+ const options = normalizeUserOptions({ sourcemaps: { deleteSourcemapsAfterUpload: true } });
+
+ expect(validateOptions(options, mockedLogger)).toBe(true);
+ expect(mockedLogger.warn).not.toHaveBeenCalled();
+ });
+
it('should return `true` for options without special cases', () => {
const options = {
org: 'my-org',
diff --git a/packages/bundler-plugins/test/core/sentry/telemetry.test.ts b/packages/bundler-plugins/test/core/sentry/telemetry.test.ts
index 49cf78b7dffa..7de02ab79bc0 100644
--- a/packages/bundler-plugins/test/core/sentry/telemetry.test.ts
+++ b/packages/bundler-plugins/test/core/sentry/telemetry.test.ts
@@ -68,6 +68,19 @@ describe('addPluginOptionTagsToScope', () => {
expect(mockedScope.setTag).toHaveBeenCalledWith('deploy-options', true);
});
+ it.each([
+ [undefined, 'auto'],
+ [true, 'true'],
+ [false, 'false'],
+ ])('should set the delete-sourcemaps-after-upload tag for `%s` to `%s`', (option, tag) => {
+ setTelemetryDataOnScope(
+ normalizeUserOptions({ ...defaultOptions, sourcemaps: { deleteSourcemapsAfterUpload: option } }),
+ mockedScope as unknown as Scope,
+ 'rollup',
+ );
+ expect(mockedScope.setTag).toHaveBeenCalledWith('delete-sourcemaps-after-upload', tag);
+ });
+
it('should set errorHandler tag to `custom` if the errorHandler option is specified', () => {
setTelemetryDataOnScope(
// eslint-disable-next-line @typescript-eslint/no-empty-function
diff --git a/packages/bundler-plugins/test/core/sourcemap-settings.test.ts b/packages/bundler-plugins/test/core/sourcemap-settings.test.ts
new file mode 100644
index 000000000000..4435fb98662e
--- /dev/null
+++ b/packages/bundler-plugins/test/core/sourcemap-settings.test.ts
@@ -0,0 +1,106 @@
+import { describe, it, expect } from 'vitest';
+import { normalizeUserOptions } from '../../src/core/options-mapping';
+import type { Options } from '../../src/core';
+import { decideSourceMapHandling } from '../../src/core/sourcemap-settings';
+import type { SourceMapKind } from '../../src/core/sourcemap-settings';
+
+function options(sourcemaps?: Options['sourcemaps']) {
+ return normalizeUserOptions({ org: 'o', project: 'p', authToken: 't', release: { name: 'r' }, sourcemaps });
+}
+
+const NO_CHANGE = { enableHidden: false, deleteAfterUpload: false };
+
+describe('decideSourceMapHandling', () => {
+ describe('with the option unset (auto)', () => {
+ it('enables hidden source maps and deletion when the bundler setting is unset', () => {
+ const decision = decideSourceMapHandling('unset', options(), true);
+
+ expect(decision).toMatchObject({ enableHidden: true, deleteAfterUpload: true });
+ expect(decision.log?.level).toBe('info');
+ });
+
+ it.each(['hidden', 'visible'])('leaves a `%s` setting alone', kind => {
+ expect(decideSourceMapHandling(kind, options(), true)).toEqual(NO_CHANGE);
+ });
+
+ it('warns when source maps are turned off', () => {
+ const decision = decideSourceMapHandling('off', options(), true);
+
+ expect(decision).toMatchObject(NO_CHANGE);
+ expect(decision.log?.level).toBe('warn');
+ expect(decision.log?.message).toMatch(/turned off/);
+ });
+
+ it('warns when source maps are inlined', () => {
+ const decision = decideSourceMapHandling('inline', options(), true);
+
+ expect(decision).toMatchObject(NO_CHANGE);
+ expect(decision.log?.level).toBe('warn');
+ expect(decision.log?.message).toMatch(/inlined/);
+ });
+ });
+
+ describe('with the option set to true', () => {
+ it('enables hidden source maps and deletion when the bundler setting is unset', () => {
+ expect(decideSourceMapHandling('unset', options({ deleteSourcemapsAfterUpload: true }), true)).toMatchObject({
+ enableHidden: true,
+ deleteAfterUpload: true,
+ });
+ });
+
+ it('deletes hidden source maps without changing the setting', () => {
+ expect(decideSourceMapHandling('hidden', options({ deleteSourcemapsAfterUpload: true }), true)).toEqual({
+ enableHidden: false,
+ deleteAfterUpload: true,
+ });
+ });
+
+ it('switches visible source maps to hidden and deletes them', () => {
+ const decision = decideSourceMapHandling('visible', options({ deleteSourcemapsAfterUpload: true }), true);
+
+ expect(decision).toMatchObject({ enableHidden: true, deleteAfterUpload: true });
+ expect(decision.log?.level).toBe('info');
+ });
+
+ it.each(['off', 'inline'])('warns and does not delete for a `%s` setting', kind => {
+ const decision = decideSourceMapHandling(kind, options({ deleteSourcemapsAfterUpload: true }), true);
+
+ expect(decision).toMatchObject(NO_CHANGE);
+ expect(decision.log?.level).toBe('warn');
+ });
+ });
+
+ describe('with the option set to false', () => {
+ it('enables hidden source maps without deletion when the bundler setting is unset', () => {
+ const decision = decideSourceMapHandling('unset', options({ deleteSourcemapsAfterUpload: false }), true);
+
+ expect(decision).toMatchObject({ enableHidden: true, deleteAfterUpload: false });
+ expect(decision.log?.level).toBe('info');
+ expect(decision.log?.message).toMatch(/kept in the build output/);
+ });
+
+ it.each(['hidden', 'visible'])('leaves a `%s` setting alone', kind => {
+ expect(decideSourceMapHandling(kind, options({ deleteSourcemapsAfterUpload: false }), true)).toEqual(NO_CHANGE);
+ });
+
+ it.each(['off', 'inline'])('still warns about a `%s` setting', kind => {
+ const decision = decideSourceMapHandling(kind, options({ deleteSourcemapsAfterUpload: false }), true);
+
+ expect(decision).toMatchObject(NO_CHANGE);
+ expect(decision.log?.level).toBe('warn');
+ });
+ });
+
+ it.each(['unset', 'off', 'visible', 'hidden', 'inline'])(
+ 'does nothing for a `%s` setting when no upload can run',
+ kind => {
+ expect(decideSourceMapHandling(kind, options({ deleteSourcemapsAfterUpload: true }), false)).toEqual(NO_CHANGE);
+ },
+ );
+
+ it.each([true, 'disable-upload'] as const)('does nothing when `sourcemaps.disable` is `%s`', disable => {
+ expect(decideSourceMapHandling('unset', options({ disable, deleteSourcemapsAfterUpload: true }), true)).toEqual(
+ NO_CHANGE,
+ );
+ });
+});
diff --git a/packages/bundler-plugins/test/core/upload-staging.test.ts b/packages/bundler-plugins/test/core/upload-staging.test.ts
new file mode 100644
index 000000000000..ab87bf4f133f
--- /dev/null
+++ b/packages/bundler-plugins/test/core/upload-staging.test.ts
@@ -0,0 +1,384 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { createSentryBuildPluginManager } from '../../src/core/build-plugin-manager';
+
+const { mockSourcemapUpload } = vi.hoisted(() => ({ mockSourcemapUpload: vi.fn() }));
+
+vi.mock('sentry', () => ({
+ createSentrySDK: () => ({
+ release: { create: vi.fn(), finalize: vi.fn(), 'set-commits': vi.fn(), deploy: vi.fn() },
+ sourcemap: { upload: mockSourcemapUpload, inject: vi.fn() },
+ run: vi.fn(),
+ }),
+}));
+
+vi.mock('../../src/core/sentry/telemetry', async () => ({
+ ...(await vi.importActual('../../src/core/sentry/telemetry')),
+ safeFlushTelemetry: vi.fn(),
+}));
+
+const DEBUG_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
+const OTHER_DEBUG_ID = '11111111-2222-3333-4444-555555555555';
+
+function chunk(debugId: string): string {
+ return `console.log(1);\n;!function(){try{var e={},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="${debugId}",e._sentryDebugIdIdentifier="sentry-dbid-${debugId}")}catch(e){}}();`;
+}
+
+const MAP = JSON.stringify({ version: 3, sources: ['/abs/src/index.ts'], mappings: 'AAAA' });
+
+function createManager(rewriteSources?: (source: string) => string) {
+ return createSentryBuildPluginManager(
+ {
+ authToken: 't',
+ org: 'o',
+ project: 'p',
+ release: { name: 'r', create: false, finalize: false },
+ telemetry: false,
+ sourcemaps: { rewriteSources },
+ },
+ { buildTool: 'rollup', loggerPrefix: '[test]' },
+ );
+}
+
+describe('createUploadStaging', () => {
+ let tmpDir: string;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockSourcemapUpload.mockResolvedValue(undefined);
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-staging-test-'));
+ });
+
+ afterEach(() => {
+ delete process.env['SENTRY_TEST_OVERRIDE_TEMP_DIR'];
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ it('writes the stamped chunk and map into the staging folder', async () => {
+ const manager = createManager(source => path.basename(source));
+ const staging = await manager.createUploadStaging();
+
+ try {
+ const result = await staging.stage({
+ code: chunk(DEBUG_ID),
+ mapSource: MAP,
+ mapDir: '/out',
+ fileName: 'index.js',
+ });
+
+ expect(result).toBe('staged');
+ expect(staging.count).toBe(1);
+ expect(fs.readdirSync(staging.dir).sort()).toEqual([`${DEBUG_ID}-0.js`, `${DEBUG_ID}-0.js.map`]);
+ expect(fs.readFileSync(path.join(staging.dir, `${DEBUG_ID}-0.js`), 'utf8')).toContain(`//# debugId=${DEBUG_ID}`);
+
+ const map = JSON.parse(fs.readFileSync(path.join(staging.dir, `${DEBUG_ID}-0.js.map`), 'utf8'));
+ expect(map).toMatchObject({ debug_id: DEBUG_ID, debugId: DEBUG_ID, sources: ['index.ts'] });
+ } finally {
+ await staging.discard();
+ }
+ });
+
+ it('passes the map directory to the rewriteSources hook', async () => {
+ const rewriteSources = vi.fn((source: string) => source);
+ const manager = createManager(rewriteSources);
+ const staging = await manager.createUploadStaging();
+
+ try {
+ await staging.stage({ code: chunk(DEBUG_ID), mapSource: MAP, mapDir: '/out/assets', fileName: 'a.js' });
+ } finally {
+ await staging.discard();
+ }
+
+ expect(rewriteSources).toHaveBeenCalledWith('/abs/src/index.ts', expect.any(Object), { mapDir: '/out/assets' });
+ });
+
+ it('skips chunks without a debug ID or without a source map and does not count them', async () => {
+ const manager = createManager();
+ const staging = await manager.createUploadStaging();
+
+ try {
+ expect(await staging.stage({ code: 'console.log(1)', mapSource: MAP, mapDir: '/out', fileName: 'a.js' })).toBe(
+ 'skipped',
+ );
+ expect(
+ await staging.stage({ code: chunk(DEBUG_ID), mapSource: undefined, mapDir: '/out', fileName: 'b.js' }),
+ ).toBe('skipped');
+ expect(staging.count).toBe(0);
+ expect(fs.readdirSync(staging.dir)).toEqual([]);
+ } finally {
+ await staging.discard();
+ }
+ });
+
+ it('removes the folder on discard', async () => {
+ const manager = createManager();
+ const staging = await manager.createUploadStaging();
+
+ await staging.stage({ code: chunk(DEBUG_ID), mapSource: MAP, mapDir: '/out', fileName: 'a.js' });
+ await staging.discard();
+ await staging.discard();
+
+ expect(fs.existsSync(staging.dir)).toBe(false);
+ });
+
+ it('keeps the folder on discard when SENTRY_TEST_OVERRIDE_TEMP_DIR is set', async () => {
+ process.env['SENTRY_TEST_OVERRIDE_TEMP_DIR'] = tmpDir;
+ const manager = createManager();
+ const staging = await manager.createUploadStaging();
+
+ expect(staging.dir).toBe(tmpDir);
+ await staging.discard();
+
+ expect(fs.existsSync(tmpDir)).toBe(true);
+ });
+
+ it('keeps the folder and logs its path on keep', async () => {
+ const manager = createManager();
+ const errorSpy = vi.spyOn(manager.logger, 'error');
+ const staging = await manager.createUploadStaging();
+ await staging.stage({ code: chunk(DEBUG_ID), mapSource: MAP, mapDir: '/out', fileName: 'a.js' });
+
+ try {
+ await staging.keep('The upload did not run.');
+ await staging.keep('The upload did not run.');
+ await staging.discard();
+
+ expect(fs.existsSync(staging.dir)).toBe(true);
+ expect(errorSpy).toHaveBeenCalledTimes(1);
+ expect(errorSpy).toHaveBeenCalledWith(
+ `The upload did not run. The source maps were kept in ${staging.dir}. Upload them with \`sentry sourcemap upload ${staging.dir}\`.`,
+ );
+ } finally {
+ fs.rmSync(staging.dir, { recursive: true, force: true });
+ }
+ });
+
+ it('removes the folder on keep when nothing was staged', async () => {
+ const manager = createManager();
+ const errorSpy = vi.spyOn(manager.logger, 'error');
+ const staging = await manager.createUploadStaging();
+
+ await staging.keep('The upload did not run.');
+
+ expect(fs.existsSync(staging.dir)).toBe(false);
+ expect(errorSpy).not.toHaveBeenCalled();
+ });
+
+ describe('uploadSourcemaps with a staging folder', () => {
+ it('uploads the staging folder and removes it afterwards', async () => {
+ const manager = createManager();
+ const staging = await manager.createUploadStaging();
+ await staging.stage({ code: chunk(DEBUG_ID), mapSource: MAP, mapDir: '/out', fileName: 'a.js' });
+
+ const result = await manager.uploadSourcemaps([], { staging });
+
+ expect(result).toEqual({ uploaded: true });
+ expect(mockSourcemapUpload).toHaveBeenCalledWith(expect.objectContaining({ directory: staging.dir }));
+ expect(fs.existsSync(staging.dir)).toBe(false);
+ });
+
+ it('does not prepare a staged chunk again from disk', async () => {
+ fs.writeFileSync(path.join(tmpDir, 'index.js'), chunk(DEBUG_ID));
+ fs.writeFileSync(
+ path.join(tmpDir, 'index.js.map'),
+ JSON.stringify({ version: 3, sources: ['stale.ts'], mappings: '' }),
+ );
+ const manager = createManager();
+ const staging = await manager.createUploadStaging();
+ await staging.stage({ code: chunk(DEBUG_ID), mapSource: MAP, mapDir: tmpDir, fileName: 'index.js' });
+
+ let uploadedFiles: string[] = [];
+ let uploadedMaps = '';
+ mockSourcemapUpload.mockImplementation(async ({ directory }: { directory: string }) => {
+ uploadedFiles = fs.readdirSync(directory).sort();
+ uploadedMaps = uploadedFiles
+ .filter(file => file.endsWith('.map'))
+ .map(file => fs.readFileSync(path.join(directory, file), 'utf8'))
+ .join();
+ });
+
+ const result = await manager.uploadSourcemaps([path.join(tmpDir, '**/*.js')], { staging });
+
+ expect(result).toEqual({ uploaded: true });
+ expect(uploadedFiles).toEqual([`${DEBUG_ID}-0.js`, `${DEBUG_ID}-0.js.map`]);
+ expect(uploadedMaps).not.toContain('stale.ts');
+ });
+
+ it('numbers artifacts prepared from disk after the staged ones', async () => {
+ const outDir = path.join(tmpDir, 'out');
+ fs.mkdirSync(outDir);
+ fs.writeFileSync(path.join(outDir, 'disk.js'), `${chunk(OTHER_DEBUG_ID)}\n//# sourceMappingURL=disk.js.map`);
+ fs.writeFileSync(path.join(outDir, 'disk.js.map'), MAP);
+
+ const manager = createManager();
+ const staging = await manager.createUploadStaging();
+ await staging.stage({ code: chunk(DEBUG_ID), mapSource: MAP, mapDir: outDir, fileName: 'staged.js' });
+
+ let uploadedFiles: string[] = [];
+ mockSourcemapUpload.mockImplementation(async ({ directory }: { directory: string }) => {
+ uploadedFiles = fs.readdirSync(directory).sort();
+ });
+
+ await manager.uploadSourcemaps([path.join(outDir, 'disk.js')], { staging });
+
+ expect(uploadedFiles).toEqual([
+ `${OTHER_DEBUG_ID}-1.js`,
+ `${OTHER_DEBUG_ID}-1.js.map`,
+ `${DEBUG_ID}-0.js`,
+ `${DEBUG_ID}-0.js.map`,
+ ]);
+ });
+
+ it('does not warn about missing sources when artifacts were staged', async () => {
+ const manager = createManager();
+ const warnSpy = vi.spyOn(manager.logger, 'warn');
+ const staging = await manager.createUploadStaging();
+ await staging.stage({ code: chunk(DEBUG_ID), mapSource: MAP, mapDir: '/out', fileName: 'a.js' });
+
+ await manager.uploadSourcemaps([], { staging });
+
+ expect(warnSpy).not.toHaveBeenCalled();
+ expect(mockSourcemapUpload).toHaveBeenCalledTimes(1);
+ });
+
+ it('discards an empty staging folder and warns when nothing matched on disk', async () => {
+ const manager = createManager();
+ const warnSpy = vi.spyOn(manager.logger, 'warn');
+ const staging = await manager.createUploadStaging();
+
+ const result = await manager.uploadSourcemaps([], { staging });
+
+ expect(result).toEqual({ uploaded: false });
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringMatching(/matching sources/));
+ expect(mockSourcemapUpload).not.toHaveBeenCalled();
+ expect(fs.existsSync(staging.dir)).toBe(false);
+ });
+
+ it('discards the staging folder when the upload cannot run', async () => {
+ const manager = createSentryBuildPluginManager(
+ { org: 'o', project: 'p', release: { name: 'r' }, telemetry: false },
+ { buildTool: 'rollup', loggerPrefix: '[test]' },
+ );
+ const staging = await manager.createUploadStaging();
+ await staging.stage({ code: chunk(DEBUG_ID), mapSource: MAP, mapDir: '/out', fileName: 'a.js' });
+
+ const result = await manager.uploadSourcemaps([], { staging });
+
+ expect(result).toEqual({ uploaded: false });
+ expect(mockSourcemapUpload).not.toHaveBeenCalled();
+ expect(fs.existsSync(staging.dir)).toBe(false);
+ });
+
+ it('keeps the staging folder and logs its path when the upload fails', async () => {
+ mockSourcemapUpload.mockRejectedValue(new Error('upload failed'));
+ const manager = createManager();
+ const errorSpy = vi.spyOn(manager.logger, 'error');
+ const staging = await manager.createUploadStaging();
+ await staging.stage({ code: chunk(DEBUG_ID), mapSource: MAP, mapDir: '/out', fileName: 'a.js' });
+
+ try {
+ const result = await manager.uploadSourcemaps([], { staging });
+
+ expect(result).toEqual({ uploaded: false });
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining(`sentry sourcemap upload ${staging.dir}`));
+ expect(fs.existsSync(staging.dir)).toBe(true);
+ } finally {
+ await staging.discard();
+ }
+ });
+
+ it('uploads the staging folder next to the direct paths when prepareArtifacts is false', async () => {
+ const manager = createManager();
+ const staging = await manager.createUploadStaging();
+ await staging.stage({ code: chunk(DEBUG_ID), mapSource: MAP, mapDir: '/out', fileName: 'a.js' });
+
+ const result = await manager.uploadSourcemaps(['.next/server'], { staging, prepareArtifacts: false });
+
+ expect(result).toEqual({ uploaded: true });
+ expect(mockSourcemapUpload).toHaveBeenCalledTimes(2);
+ expect(mockSourcemapUpload).toHaveBeenCalledWith(expect.objectContaining({ directory: '.next/server' }));
+ expect(mockSourcemapUpload).toHaveBeenCalledWith(expect.objectContaining({ directory: staging.dir }));
+ expect(fs.existsSync(staging.dir)).toBe(false);
+ });
+
+ it('skips an empty staging folder when prepareArtifacts is false', async () => {
+ const manager = createManager();
+ const staging = await manager.createUploadStaging();
+
+ await manager.uploadSourcemaps(['.next/server'], { staging, prepareArtifacts: false });
+
+ expect(mockSourcemapUpload).toHaveBeenCalledTimes(1);
+ expect(mockSourcemapUpload).toHaveBeenCalledWith(expect.objectContaining({ directory: '.next/server' }));
+ expect(fs.existsSync(staging.dir)).toBe(false);
+ });
+
+ it('reports no upload when there is nothing to upload and prepareArtifacts is false', async () => {
+ const manager = createManager();
+ const warnSpy = vi.spyOn(manager.logger, 'warn');
+
+ const result = await manager.uploadSourcemaps([], { prepareArtifacts: false });
+
+ expect(result).toEqual({ uploaded: false });
+ expect(mockSourcemapUpload).not.toHaveBeenCalled();
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Didn't find any matching sources"));
+ });
+ });
+});
+
+describe('sourceMaps', () => {
+ afterEach(() => {
+ delete process.env['NODE_ENV'];
+ });
+
+ it('reports that an upload is possible with credentials', () => {
+ expect(createManager().sourceMaps.uploadPossible).toBe(true);
+ });
+
+ it.each([
+ ['no auth token', { org: 'o', project: 'p' }],
+ ['no project', { org: 'o', authToken: 't' }],
+ ['upload disabled', { org: 'o', project: 'p', authToken: 't', sourcemaps: { disable: true as const } }],
+ ['an empty assets list', { org: 'o', project: 'p', authToken: 't', sourcemaps: { assets: [] } }],
+ ])('reports that no upload is possible with %s', (_, options) => {
+ const manager = createSentryBuildPluginManager(
+ { ...options, release: { name: 'r' }, telemetry: false },
+ { buildTool: 'rollup', loggerPrefix: '[test]' },
+ );
+
+ expect(manager.sourceMaps.uploadPossible).toBe(false);
+ expect(manager.sourceMaps.decide('unset')).toEqual({ enableHidden: false, deleteAfterUpload: false });
+ });
+
+ it('reports that no upload is possible in development mode', () => {
+ process.env['NODE_ENV'] = 'development';
+
+ expect(createManager().sourceMaps.uploadPossible).toBe(false);
+ });
+
+ it('logs the decision message once', () => {
+ const manager = createManager();
+ const infoSpy = vi.spyOn(manager.logger, 'info');
+ const { decide } = manager.sourceMaps;
+
+ expect(decide('unset')).toMatchObject({ enableHidden: true, deleteAfterUpload: true });
+ expect(decide('unset')).toMatchObject({ enableHidden: true, deleteAfterUpload: true });
+
+ expect(infoSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('is inert on a disabled plugin', async () => {
+ const manager = createSentryBuildPluginManager(
+ { disable: true, org: 'o', project: 'p', authToken: 't' },
+ { buildTool: 'rollup', loggerPrefix: '[test]' },
+ );
+
+ expect(manager.sourceMaps.uploadPossible).toBe(false);
+ expect(manager.sourceMaps.decide('unset')).toEqual({ enableHidden: false, deleteAfterUpload: false });
+
+ const staging = await manager.createUploadStaging();
+ expect(await staging.stage({ code: '', mapSource: undefined, mapDir: '', fileName: 'a.js' })).toBe('skipped');
+ expect(await manager.uploadSourcemaps([], { staging })).toEqual({ uploaded: false });
+ });
+});
diff --git a/packages/bundler-plugins/test/esbuild/rebuild.test.ts b/packages/bundler-plugins/test/esbuild/rebuild.test.ts
new file mode 100644
index 000000000000..be9b74d558c9
--- /dev/null
+++ b/packages/bundler-plugins/test/esbuild/rebuild.test.ts
@@ -0,0 +1,57 @@
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import * as esbuild from 'esbuild';
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { sentryEsbuildPlugin } from '../../src/esbuild';
+
+const DEBUG_ID_MARKER = /sentry-dbid-([0-9a-f-]{36})/;
+
+describe('rebuilds', () => {
+ let tmpDir: string;
+ let entry: string;
+ let outDir: string;
+
+ beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-esbuild-rebuild-'));
+ entry = path.join(tmpDir, 'entry.js');
+ outDir = path.join(tmpDir, 'dist');
+ fs.writeFileSync(entry, 'export const answer = 42;\nconsole.log(answer);\n');
+ });
+
+ afterEach(() => {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ function readDebugIds(): { bundle: string | undefined; map: unknown } {
+ const bundle = fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8');
+ const map = JSON.parse(fs.readFileSync(path.join(outDir, 'entry.js.map'), 'utf8'));
+ return { bundle: bundle.match(DEBUG_ID_MARKER)?.[1], map: map.debug_id };
+ }
+
+ it('injects a debug ID into every rebuild of a context', async () => {
+ const context = await esbuild.context({
+ entryPoints: [entry],
+ bundle: true,
+ sourcemap: true,
+ outdir: outDir,
+ absWorkingDir: tmpDir,
+ plugins: [sentryEsbuildPlugin({ telemetry: false, sourcemaps: { disable: 'disable-upload' } })],
+ });
+
+ try {
+ await context.rebuild();
+ const first = readDebugIds();
+ expect(first.bundle).toBeDefined();
+ expect(first.map).toBe(first.bundle);
+
+ fs.writeFileSync(entry, 'export const answer = 43;\nconsole.log(answer);\n');
+ await context.rebuild();
+ const second = readDebugIds();
+ expect(second.bundle).toBeDefined();
+ expect(second.map).toBe(second.bundle);
+ } finally {
+ await context.dispose();
+ }
+ });
+});
diff --git a/packages/bundler-plugins/test/rollup/literal-artifact-paths.test.ts b/packages/bundler-plugins/test/rollup/literal-artifact-paths.test.ts
new file mode 100644
index 000000000000..b1434b349133
--- /dev/null
+++ b/packages/bundler-plugins/test/rollup/literal-artifact-paths.test.ts
@@ -0,0 +1,70 @@
+import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join, sep } from 'node:path';
+import { rollup } from 'rollup';
+import { afterEach, beforeEach, expect, test, vi } from 'vitest';
+import { sentryRollupPlugin } from '../../src/rollup';
+
+const upload = vi.hoisted(() => vi.fn());
+const fixtures: string[] = [];
+vi.mock('sentry', () => ({
+ createSentrySDK: () => ({
+ sourcemap: { upload },
+ release: {},
+ }),
+}));
+
+beforeEach(() => {
+ upload.mockReset();
+});
+
+afterEach(() => {
+ for (const fixture of fixtures.splice(0)) {
+ rmSync(fixture, { recursive: true, force: true });
+ }
+});
+
+test.each([
+ { name: 'ordinary filenames', file: 'sdk.mjs', explicitAssets: false, nativePath: false },
+ { name: 'glob characters in detected paths', file: 'sdk+[...].mjs', explicitAssets: false, nativePath: false },
+ { name: 'explicit asset globs', file: 'sdk+[...].mjs', explicitAssets: true, nativePath: false },
+ { name: 'native path separators', file: 'sdk+[...].mjs', explicitAssets: false, nativePath: true },
+ { name: 'ignored assets', file: 'sdk+[...].mjs', explicitAssets: false, nativePath: false, ignored: true },
+])('handles $name when uploading Rollup artifacts', async ({ file, explicitAssets, nativePath, ignored }) => {
+ const fixture = mkdtempSync(join(tmpdir(), 'sentry-rollup-literal-paths-'));
+ fixtures.push(fixture);
+ const input = join(fixture, 'input.js');
+ const output = join(fixture, 'dist', '_libs', file);
+ writeFileSync(input, 'export function example() { throw new Error("original fixture"); }\n');
+ const uploadedFiles: string[] = [];
+ upload.mockImplementation(async (options: { directory: string }) => {
+ uploadedFiles.push(...readdirSync(options.directory));
+ });
+
+ const bundle = await rollup({
+ input,
+ plugins: sentryRollupPlugin({
+ authToken: 'local-fixture-only',
+ org: 'local-fixture',
+ project: 'local-fixture',
+ telemetry: false,
+ silent: true,
+ release: { create: false, finalize: false },
+ sourcemaps: {
+ assets: explicitAssets ? join(fixture, 'dist', '**', '*.mjs').split(sep).join('/') : undefined,
+ ignore: ignored ? join(fixture, 'dist', '**', 'sdk+*.mjs').split(sep).join('/') : undefined,
+ },
+ }),
+ });
+ try {
+ await bundle.write({ file: nativePath ? output : output.replace(/\\/g, '/'), format: 'es', sourcemap: true });
+ } finally {
+ await bundle.close();
+ }
+
+ expect(existsSync(output)).toBe(true);
+ expect(existsSync(`${output}.map`)).toBe(true);
+ expect(readFileSync(output, 'utf8')).toContain('sentry-dbid-');
+ expect(uploadedFiles.filter(name => name.endsWith('.js'))).toHaveLength(ignored ? 0 : 1);
+ expect(uploadedFiles.filter(name => name.endsWith('.js.map'))).toHaveLength(ignored ? 0 : 1);
+});
diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json
index e899cfdef0d7..ba846efb84eb 100644
--- a/packages/cloudflare/package.json
+++ b/packages/cloudflare/package.json
@@ -53,7 +53,7 @@
},
"dependencies": {
"@opentelemetry/api": "^1.9.1",
- "@sentry/conventions": "^0.29.0",
+ "@sentry/conventions": "^0.30.0",
"@sentry/core": "11.10.0",
"@sentry/opentelemetry": "11.10.0",
"@sentry/server-utils": "11.10.0",
@@ -80,8 +80,8 @@
"@sentry-internal/rollup-utils": "11.10.0",
"@types/node": "^20.19.0",
"acorn": "^8.14.0",
- "vite": "^8.3.1",
- "wrangler": "4.61.0"
+ "vite": "^8.3.5",
+ "wrangler": "4.156.0"
},
"scripts": {
"build": "run-p build:transpile build:types",
diff --git a/packages/cloudflare/src/baseSdk.ts b/packages/cloudflare/src/baseSdk.ts
index af1d2d681f66..43b5b9604fcd 100644
--- a/packages/cloudflare/src/baseSdk.ts
+++ b/packages/cloudflare/src/baseSdk.ts
@@ -41,14 +41,10 @@ function getRegisteredChannelIntegrations(): Integration[] {
}
/**
- * Get the default integrations that run on any Workers-compatible runtime, i.e. without the
- * `nodejs_compat` compatibility flag.
- *
- * `getDefaultIntegrations` in `sdk.ts` extends this set with the integrations that do depend on
- * Node.js APIs. Keeping the two apart is what allows `wrapRequestHandler` to stay usable on runtimes
- * that cannot enable `nodejs_compat`, such as Shopify Oxygen.
+ * Get the default integrations for the Cloudflare SDK.
+ * These also work on Workers-compatible runtimes without the `nodejs_compat` compatibility flag.
*/
-export function getBaseDefaultIntegrations(options: CloudflareOptions): Integration[] {
+export function getDefaultIntegrations(options: CloudflareOptions): Integration[] {
return [
// The Dedupe integration should not be used in workflows because we want to
// capture all step failures, even if they are the same error.
@@ -72,12 +68,10 @@ export function getBaseDefaultIntegrations(options: CloudflareOptions): Integrat
}
/**
- * Initializes the Cloudflare SDK with the passed default integrations.
+ * Initializes the Cloudflare SDK without setting up an OpenTelemetry tracer provider.
*
- * The default integrations are injected rather than imported so that this module stays free of
- * Node.js-only code. `request.ts` — which backs both `wrapRequestHandler` and the
- * `@sentry/cloudflare/request` entry point, and therefore has to work on runtimes without the
- * `nodejs_compat` compatibility flag — creates its client from here instead of from `sdk.ts`.
+ * Kept separate from `sdk.ts` so `@sentry/cloudflare/request` can load without Node.js-only
+ * dependencies on runtimes such as Shopify Oxygen.
*
* The client is cached and reused across invocations within the same isolate,
* unless `cacheClient: false` is passed. This avoids the
@@ -87,10 +81,7 @@ export function getBaseDefaultIntegrations(options: CloudflareOptions): Integrat
* boundary that reliably extends execution, so spans/events that end after
* disposal would otherwise be lost.
*/
-export function initWithDefaultIntegrations(
- options: CloudflareOptions,
- getDefaultIntegrationsImpl: (options: CloudflareOptions) => Integration[],
-): CloudflareClient | undefined {
+export function initBaseSdk(options: CloudflareOptions): CloudflareClient | undefined {
const cached = getCachedClient();
const cacheEnabled = options.cacheClient !== false;
@@ -111,7 +102,7 @@ export function initWithDefaultIntegrations(
// included, unless an SDK built the list from the defaults.
const usesSdkDefaults = options.defaultIntegrations === undefined || !!options._extendsDefaultIntegrations;
if (options.defaultIntegrations === undefined) {
- options.defaultIntegrations = getDefaultIntegrationsImpl(options);
+ options.defaultIntegrations = getDefaultIntegrations(options);
}
// A cached client outlives any single invocation, so binding it to one
@@ -191,12 +182,3 @@ export function initWithDefaultIntegrations(
return client;
}
-
-/**
- * Initializes the Cloudflare SDK with only the default integrations from
- * {@link getBaseDefaultIntegrations}, i.e. those that work without the `nodejs_compat`
- * compatibility flag.
- */
-export function initBaseSdk(options: CloudflareOptions): CloudflareClient | undefined {
- return initWithDefaultIntegrations(options, getBaseDefaultIntegrations);
-}
diff --git a/packages/cloudflare/src/instrumentations/instrumentDurableObjectStorage.ts b/packages/cloudflare/src/instrumentations/instrumentDurableObjectStorage.ts
index e1d1a3fc1025..eabad9dee209 100644
--- a/packages/cloudflare/src/instrumentations/instrumentDurableObjectStorage.ts
+++ b/packages/cloudflare/src/instrumentations/instrumentDurableObjectStorage.ts
@@ -1,5 +1,5 @@
import type { DurableObjectStorage, SyncKvStorage, SqlStorage } from '@cloudflare/workers-types';
-import { SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes';
+import { SENTRY_OP, SENTRY_ORIGIN, DB_OPERATION_NAME, DB_SYSTEM_NAME } from '@sentry/conventions/attributes';
import { DB } from '@sentry/conventions/op';
import { getClient, isThenable, startSpan } from '@sentry/core';
import type { CloudflareClientOptions } from '../client';
@@ -76,8 +76,8 @@ export function instrumentDurableObjectStorage(
attributes: {
[SENTRY_OP]: DB,
[SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object',
- 'db.system.name': 'cloudflare.durable_object.storage',
- 'db.operation.name': methodName,
+ [DB_SYSTEM_NAME]: 'cloudflare.durable_object.storage',
+ [DB_OPERATION_NAME]: methodName,
},
},
() => {
diff --git a/packages/cloudflare/src/instrumentations/instrumentDurableObjectSyncKvStorage.ts b/packages/cloudflare/src/instrumentations/instrumentDurableObjectSyncKvStorage.ts
index eb21b57a2fbf..2ab1d58d69f0 100644
--- a/packages/cloudflare/src/instrumentations/instrumentDurableObjectSyncKvStorage.ts
+++ b/packages/cloudflare/src/instrumentations/instrumentDurableObjectSyncKvStorage.ts
@@ -1,5 +1,5 @@
import type { SyncKvStorage } from '@cloudflare/workers-types';
-import { SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes';
+import { SENTRY_OP, SENTRY_ORIGIN, DB_OPERATION_NAME, DB_SYSTEM_NAME } from '@sentry/conventions/attributes';
import { DB } from '@sentry/conventions/op';
import { startSpan } from '@sentry/core';
@@ -31,8 +31,8 @@ export function instrumentDurableObjectSyncKvStorage(syncKv: SyncKvStorage): Syn
[SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object',
// keeping the value as close as possible to the Cloudflare Worker KV instrumentation
// https://github.com/cloudflare/workerd/blob/6b8b11787e2b2a800ab0edd0690bfab3857b0529/src/workerd/api/sync-kv.c%2B%2B#L19
- 'db.system.name': 'cloudflare-durable-object-sql',
- 'db.operation.name': methodName,
+ [DB_SYSTEM_NAME]: 'cloudflare-durable-object-sql',
+ [DB_OPERATION_NAME]: methodName,
},
},
() => {
diff --git a/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts b/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts
index 8147a2a2bdbb..61dfed37366b 100644
--- a/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts
+++ b/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts
@@ -1,5 +1,13 @@
import type { SqlStorage } from '@cloudflare/workers-types';
-import { SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes';
+import {
+ SENTRY_OP,
+ SENTRY_ORIGIN,
+ CLOUDFLARE_DURABLE_OBJECT_QUERY_BINDINGS,
+ DB_OPERATION_NAME,
+ DB_QUERY_SUMMARY,
+ DB_QUERY_TEXT,
+ DB_SYSTEM_NAME,
+} from '@sentry/conventions/attributes';
import { DB_QUERY } from '@sentry/conventions/op';
import { getActiveSpan, getClient, hasSpansEnabled, spanIsSampled, startSpan } from '@sentry/core';
import { getSqlQuerySummary, sanitizeSqlQuery } from '@sentry/server-utils';
@@ -9,8 +17,8 @@ import { mayTargetCloudflareInternalTable, targetsCloudflareInternalTable } from
const SPAN_ATTRIBUTES = {
[SENTRY_OP]: DB_QUERY,
[SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object_sql',
- 'db.system.name': 'cloudflare-durable-object-sql',
- 'db.operation.name': 'exec',
+ [DB_SYSTEM_NAME]: 'cloudflare-durable-object-sql',
+ [DB_OPERATION_NAME]: 'exec',
};
/**
@@ -58,9 +66,9 @@ export function instrumentSqlStorage(sql: SqlStorage): SqlStorage {
name: querySummary || sanitizedQuery,
attributes: {
...SPAN_ATTRIBUTES,
- 'db.query.text': sanitizedQuery,
- 'db.query.summary': querySummary,
- 'cloudflare.durable_object.query.bindings': bindings.length,
+ [DB_QUERY_TEXT]: sanitizedQuery,
+ [DB_QUERY_SUMMARY]: querySummary,
+ [CLOUDFLARE_DURABLE_OBJECT_QUERY_BINDINGS]: bindings.length,
},
},
callOriginal,
diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentD1.ts b/packages/cloudflare/src/instrumentations/worker/instrumentD1.ts
index 42b01428816c..f76426956138 100644
--- a/packages/cloudflare/src/instrumentations/worker/instrumentD1.ts
+++ b/packages/cloudflare/src/instrumentations/worker/instrumentD1.ts
@@ -1,6 +1,17 @@
/* eslint-disable @typescript-eslint/unbound-method */
import type { D1Database, D1DatabaseSession, D1PreparedStatement, D1Response } from '@cloudflare/workers-types';
-import { SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes';
+import {
+ SENTRY_OP,
+ SENTRY_ORIGIN,
+ CLOUDFLARE_D1_DURATION,
+ CLOUDFLARE_D1_ROWS_READ,
+ CLOUDFLARE_D1_ROWS_WRITTEN,
+ DB_OPERATION_BATCH_SIZE,
+ DB_OPERATION_NAME,
+ DB_QUERY_SUMMARY,
+ DB_QUERY_TEXT,
+ DB_SYSTEM_NAME,
+} from '@sentry/conventions/attributes';
import { DB_QUERY } from '@sentry/conventions/op';
import type { Span, SpanAttributes, StartSpanOptions } from '@sentry/core';
import { addBreadcrumb, getClient, hasSpanStreamingEnabled, SPAN_STATUS_ERROR, startSpan } from '@sentry/core';
@@ -106,9 +117,9 @@ function applyD1ReturnObjectToSpan(span: Span, d1Result: D1Response): void {
function getAttributesFromD1Response(d1Result: D1Response): SpanAttributes {
return {
- 'cloudflare.d1.duration': d1Result.meta.duration,
- 'cloudflare.d1.rows_read': d1Result.meta.rows_read,
- 'cloudflare.d1.rows_written': d1Result.meta.rows_written,
+ [CLOUDFLARE_D1_DURATION]: d1Result.meta.duration,
+ [CLOUDFLARE_D1_ROWS_READ]: d1Result.meta.rows_read,
+ [CLOUDFLARE_D1_ROWS_WRITTEN]: d1Result.meta.rows_written,
};
}
@@ -121,7 +132,7 @@ function createD1Breadcrumb(message: string | undefined, type: D1QueryType, d1Re
message,
data: {
...(d1Result ? getAttributesFromD1Response(d1Result) : {}),
- 'db.operation.name': type,
+ [DB_OPERATION_NAME]: type,
},
});
}
@@ -137,10 +148,10 @@ function createStartSpanOptions(queryText: string | undefined, type: D1QueryType
name,
attributes: {
[SENTRY_OP]: DB_QUERY,
- 'db.system.name': 'cloudflare-d1',
- 'db.operation.name': type,
- 'db.query.text': queryText,
- 'db.query.summary': querySummary,
+ [DB_SYSTEM_NAME]: 'cloudflare-d1',
+ [DB_OPERATION_NAME]: type,
+ [DB_QUERY_TEXT]: queryText,
+ [DB_QUERY_SUMMARY]: querySummary,
[SENTRY_ORIGIN]: 'auto.db.cloudflare.d1',
},
};
@@ -176,10 +187,10 @@ function instrumentBatch(
name: 'D1 batch',
attributes: {
[SENTRY_OP]: DB_QUERY,
- 'db.system.name': 'cloudflare-d1',
- 'db.operation.name': 'batch',
- 'db.query.text': queryText || undefined,
- 'db.operation.batch.size': statements.length,
+ [DB_SYSTEM_NAME]: 'cloudflare-d1',
+ [DB_OPERATION_NAME]: 'batch',
+ [DB_QUERY_TEXT]: queryText || undefined,
+ [DB_OPERATION_BATCH_SIZE]: statements.length,
[SENTRY_ORIGIN]: 'auto.db.cloudflare.d1',
},
},
diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts b/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts
index b12f01474d6a..34e2d8bddd7c 100644
--- a/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts
+++ b/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts
@@ -1,7 +1,18 @@
import type { MessageBatch } from '@cloudflare/workers-types';
import type { AnyExportedHandler } from '../../types';
import type { env as cloudflareEnv, WorkerEntrypoint } from 'cloudflare:workers';
-import { SENTRY_SEGMENT_NAME_SOURCE, SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes';
+import {
+ SENTRY_SEGMENT_NAME_SOURCE,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
+ FAAS_TRIGGER,
+ MESSAGING_BATCH_MESSAGE_COUNT,
+ MESSAGING_DESTINATION_NAME,
+ MESSAGING_MESSAGE_RETRY_COUNT,
+ MESSAGING_OPERATION_NAME,
+ MESSAGING_OPERATION_TYPE,
+ MESSAGING_SYSTEM,
+} from '@sentry/conventions/attributes';
import { QUEUE_PROCESS } from '@sentry/conventions/op';
import { captureException, startSpan, withIsolationScope } from '@sentry/core';
import type { CloudflareOptions } from '../../client';
@@ -38,13 +49,13 @@ function wrapQueueHandler(
name: `process ${batch.queue}`,
attributes: {
[SENTRY_OP]: QUEUE_PROCESS,
- 'faas.trigger': 'pubsub',
- 'messaging.destination.name': batch.queue,
- 'messaging.system': 'cloudflare',
- 'messaging.operation.type': 'process',
- 'messaging.operation.name': 'process',
- 'messaging.batch.message_count': batch.messages.length,
- 'messaging.message.retry.count': batch.messages.reduce((acc, message) => acc + message.attempts - 1, 0),
+ [FAAS_TRIGGER]: 'pubsub',
+ [MESSAGING_DESTINATION_NAME]: batch.queue,
+ [MESSAGING_SYSTEM]: 'cloudflare',
+ [MESSAGING_OPERATION_TYPE]: 'process',
+ [MESSAGING_OPERATION_NAME]: 'process',
+ [MESSAGING_BATCH_MESSAGE_COUNT]: batch.messages.length,
+ [MESSAGING_MESSAGE_RETRY_COUNT]: batch.messages.reduce((acc, message) => acc + message.attempts - 1, 0),
[SENTRY_ORIGIN]: 'auto.faas.cloudflare.queue',
[SENTRY_SEGMENT_NAME_SOURCE]: 'task',
},
diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentQueueProducer.ts b/packages/cloudflare/src/instrumentations/worker/instrumentQueueProducer.ts
index 3460ea32c633..804cf3119d51 100644
--- a/packages/cloudflare/src/instrumentations/worker/instrumentQueueProducer.ts
+++ b/packages/cloudflare/src/instrumentations/worker/instrumentQueueProducer.ts
@@ -1,5 +1,14 @@
import type { MessageSendRequest, Queue, QueueSendBatchOptions, QueueSendOptions } from '@cloudflare/workers-types';
-import { SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes';
+import {
+ SENTRY_OP,
+ SENTRY_ORIGIN,
+ MESSAGING_BATCH_MESSAGE_COUNT,
+ MESSAGING_DESTINATION_NAME,
+ MESSAGING_MESSAGE_BODY_SIZE,
+ MESSAGING_OPERATION_NAME,
+ MESSAGING_OPERATION_TYPE,
+ MESSAGING_SYSTEM,
+} from '@sentry/conventions/attributes';
import { QUEUE_PUBLISH } from '@sentry/conventions/op';
import { startSpan } from '@sentry/core';
@@ -19,12 +28,12 @@ function startPublishSpan(
{
name: `send ${bindingName}`,
attributes: {
- 'messaging.system': 'cloudflare',
- 'messaging.destination.name': bindingName,
- 'messaging.operation.type': 'send',
- 'messaging.operation.name': 'send',
- ...(messageCount !== undefined && { 'messaging.batch.message_count': messageCount }),
- 'messaging.message.body.size': bodySize,
+ [MESSAGING_SYSTEM]: 'cloudflare',
+ [MESSAGING_DESTINATION_NAME]: bindingName,
+ [MESSAGING_OPERATION_TYPE]: 'send',
+ [MESSAGING_OPERATION_NAME]: 'send',
+ [MESSAGING_BATCH_MESSAGE_COUNT]: messageCount,
+ [MESSAGING_MESSAGE_BODY_SIZE]: bodySize,
[SENTRY_OP]: QUEUE_PUBLISH,
[SENTRY_ORIGIN]: ORIGIN,
},
diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentR2.ts b/packages/cloudflare/src/instrumentations/worker/instrumentR2.ts
index a2ffdf72e50a..afc740cc250f 100644
--- a/packages/cloudflare/src/instrumentations/worker/instrumentR2.ts
+++ b/packages/cloudflare/src/instrumentations/worker/instrumentR2.ts
@@ -64,7 +64,7 @@ function createSpanOptions(bindingName: string, r2Op: R2OperationKey, key?: stri
attributes: {
[CLOUDFLARE_R2_OPERATION]: operation,
[CLOUDFLARE_R2_BUCKET]: bindingName,
- ...(requestKey !== undefined && { [CLOUDFLARE_R2_REQUEST_KEY]: requestKey }),
+ [CLOUDFLARE_R2_REQUEST_KEY]: requestKey,
...(isR2ListOptions(key) && key.prefix !== undefined && { [CLOUDFLARE_R2_REQUEST_PREFIX]: key.prefix }),
...(isR2ListOptions(key) && key.delimiter !== undefined && { [CLOUDFLARE_R2_REQUEST_DELIMITER]: key.delimiter }),
[SENTRY_OP]: op,
diff --git a/packages/cloudflare/src/integrations/httpServer.ts b/packages/cloudflare/src/integrations/httpServer.ts
index 1975f168819e..8f7afaacd29f 100644
--- a/packages/cloudflare/src/integrations/httpServer.ts
+++ b/packages/cloudflare/src/integrations/httpServer.ts
@@ -1,15 +1,6 @@
-import type { Client, IntegrationFn, MaxRequestBodySize, Span } from '@sentry/core';
-import {
- captureBodyFromWinterCGRequest,
- defineIntegration,
- getIsolationScope,
- hasSpansEnabled,
- parseStringToURLObject,
- safeCallback,
-} from '@sentry/core';
-import type { AssertTrue, CheckServerRequestOptions } from '@sentry/core/server';
-import { isStaticAssetRequest } from '@sentry/core/server';
-import { DEBUG_BUILD } from '../debug-build';
+import type { Client, Integration, IntegrationFn, MaxRequestBodySize, Span } from '@sentry/core';
+import { defineIntegration } from '@sentry/core';
+import type { AssertTrue, CheckServerRequestOptions, ServerRequestOptions } from '@sentry/core/server';
const INTEGRATION_NAME = 'HttpServer' as const;
@@ -100,21 +91,46 @@ type _ServerRequestOptionsCheck = AssertTrue<
>
>;
-interface HttpServerIntegrationInstance extends HttpServerIntegrationOptions {
- name: string;
- maxRequestBodySize: MaxRequestBodySize | undefined;
+type SupportedServerRequestOptions = Omit<
+ ServerRequestOptions,
+ 'sessions' | 'sessionFlushingDelayMS'
+>;
+
+// Every supported shared option is a required key, so a new one fails to compile below until the
+// integration passes it on.
+type HttpServerRequestOptions = {
+ [K in keyof Required]: SupportedServerRequestOptions[K];
+};
+
+interface HttpServerIntegration extends Integration {
+ requestOptions: HttpServerRequestOptions;
+}
+
+/**
+ * The incoming request options of the client's `httpServerIntegration`. If the integration does not
+ * set `maxRequestBodySize`, `dataCollection.httpBodies` decides. Without the integration, the SDK
+ * attaches no request body.
+ */
+export function getHttpServerRequestOptions(client: Client): ServerRequestOptions {
+ return (
+ client.getIntegrationByName(INTEGRATION_NAME)?.requestOptions ?? {
+ maxRequestBodySize: 'none',
+ }
+ );
}
-const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}): HttpServerIntegrationInstance => {
+const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}): HttpServerIntegration => {
return {
name: INTEGRATION_NAME,
- maxRequestBodySize: options.maxRequestBodySize,
- ignoreRequestBody: options.ignoreRequestBody,
- ignoreIncomingRequests: options.ignoreIncomingRequests,
- ignoreStaticAssets: options.ignoreStaticAssets ?? true,
- spans: options.spans,
- onSpanCreated: options.onSpanCreated,
- onSpanEnd: options.onSpanEnd,
+ requestOptions: {
+ maxRequestBodySize: options.maxRequestBodySize,
+ ignoreRequestBody: options.ignoreRequestBody,
+ ignoreIncomingRequests: options.ignoreIncomingRequests,
+ ignoreStaticAssets: options.ignoreStaticAssets ?? true,
+ spans: options.spans,
+ onSpanCreated: options.onSpanCreated,
+ onSpanEnd: options.onSpanEnd,
+ },
};
}) satisfies IntegrationFn;
@@ -137,102 +153,3 @@ const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}): Ht
* ```
*/
export const httpServerIntegration = defineIntegration(_httpServerIntegration);
-
-/**
- * Capture the request body based on the HttpServer integration config.
- * Called internally by `wrapRequestHandler`.
- */
-export async function captureIncomingRequestBody(client: Client, request: Request): Promise {
- const integration = client.getIntegrationByName(INTEGRATION_NAME);
-
- if (!integration) {
- return;
- }
-
- const configuredBodySize = integration.maxRequestBodySize;
- const effectiveBodySize: MaxRequestBodySize =
- configuredBodySize ??
- (client.getDataCollectionOptions().httpBodies.includes('incomingRequest') ? 'medium' : 'none');
-
- if (effectiveBodySize === 'none') {
- return;
- }
-
- // Skip GET and HEAD requests - they don't have bodies
- // Also skip OPTIONS, even if they may have a body, they might not give a lot of extra value
- if (request.method === 'GET' || request.method === 'HEAD' || request.method === 'OPTIONS') {
- return;
- }
-
- const ignoreRequestBody = integration.ignoreRequestBody;
- if (
- ignoreRequestBody &&
- // If the filter throws, skip the body so it cannot leak data the filter was meant to hide.
- safeCallback(
- DEBUG_BUILD ? 'Error in `ignoreRequestBody`, skipping the body:' : '',
- () => ignoreRequestBody(request.url, request),
- () => true,
- )
- ) {
- return;
- }
-
- const isolationScope = getIsolationScope();
- await captureBodyFromWinterCGRequest(request, isolationScope, effectiveBodySize);
-}
-
-/**
- * Whether to create a span for the incoming request, based on the HttpServer integration config.
- * Called internally by `wrapRequestHandler`.
- */
-export function shouldCreateIncomingRequestSpan(client: Client | undefined, request: Request): boolean {
- if (!client) {
- return false;
- }
-
- const integration = client.getIntegrationByName(INTEGRATION_NAME);
- if (!(integration?.spans ?? hasSpansEnabled(client.getOptions()))) {
- return false;
- }
-
- const urlObject = parseStringToURLObject(request.url);
- const urlPath = urlObject ? `${urlObject.pathname}${urlObject.search}` : '/';
- if (integration?.ignoreStaticAssets && request.method === 'GET' && isStaticAssetRequest(urlPath)) {
- return false;
- }
-
- const ignoreIncomingRequests = integration?.ignoreIncomingRequests;
- return !(
- ignoreIncomingRequests &&
- safeCallback(
- DEBUG_BUILD ? 'Error in `ignoreIncomingRequests`, creating the span:' : '',
- () => ignoreIncomingRequests(urlPath, request),
- () => false,
- )
- );
-}
-
-/**
- * The `onSpanCreated` and `onSpanEnd` hooks of the HttpServer integration, guarded so that a hook
- * that throws does not fail the request. Called internally by `wrapRequestHandler`.
- */
-export function getIncomingRequestSpanHooks(client: Client | undefined): {
- onSpanCreated: (span: Span, request: Request) => void;
- onSpanEnd: (span: Span, request: Request, response: Response) => void;
-} {
- const integration = client?.getIntegrationByName(INTEGRATION_NAME);
- return {
- onSpanCreated: (span, request) =>
- safeCallback(
- DEBUG_BUILD ? 'Error in `onSpanCreated`:' : '',
- () => integration?.onSpanCreated?.(span, request),
- () => undefined,
- ),
- onSpanEnd: (span, request, response) =>
- safeCallback(
- DEBUG_BUILD ? 'Error in `onSpanEnd`:' : '',
- () => integration?.onSpanEnd?.(span, request, response),
- () => undefined,
- ),
- };
-}
diff --git a/packages/cloudflare/src/request.ts b/packages/cloudflare/src/request.ts
index 760398c38b73..7f504dedcdef 100644
--- a/packages/cloudflare/src/request.ts
+++ b/packages/cloudflare/src/request.ts
@@ -5,10 +5,9 @@ import { type RequestHandlerWrapperOptions, wrapRequestHandlerWithInit } from '.
/**
* Wraps a cloudflare request handler in Sentry instrumentation.
*
- * The client is set up with the default integrations that work without the `nodejs_compat`
- * compatibility flag, so that this also works on runtimes that cannot enable it (e.g. Shopify
- * Oxygen). On a runtime that has `nodejs_compat`, pass `defaultIntegrations:
- * getDefaultIntegrations(options)` in `options` to get the full set instead.
+ * The client uses the same default integrations as `init`, without setting up an OpenTelemetry
+ * tracer provider. This entry point also works on runtimes that cannot enable the `nodejs_compat`
+ * compatibility flag, such as Shopify Oxygen.
*/
export function wrapRequestHandler(
wrapperOptions: Omit & {
diff --git a/packages/cloudflare/src/scope-utils.ts b/packages/cloudflare/src/scope-utils.ts
index 7c46e8af1001..22a3e05aebc7 100644
--- a/packages/cloudflare/src/scope-utils.ts
+++ b/packages/cloudflare/src/scope-utils.ts
@@ -1,13 +1,13 @@
+import { CLOUD_PROVIDER } from '@sentry/conventions/attributes';
import type { CfProperties, IncomingRequestCfProperties } from '@cloudflare/workers-types';
import type { Scope } from '@sentry/core';
-import { winterCGRequestToRequestData } from '@sentry/core';
/**
* Set cloud resource context on scope.
*/
export function addCloudResourceContext(scope: Scope): void {
scope.setContext('cloud_resource', {
- 'cloud.provider': 'cloudflare',
+ [CLOUD_PROVIDER]: 'cloudflare',
});
}
@@ -19,10 +19,3 @@ export function addCultureContext(scope: Scope, cf: IncomingRequestCfProperties
timezone: cf.timezone,
});
}
-
-/**
- * Set request data on scope
- */
-export function addRequest(scope: Scope, request: Request): void {
- scope.setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(request) });
-}
diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts
index 4f4148396040..649bd589794e 100644
--- a/packages/cloudflare/src/sdk.ts
+++ b/packages/cloudflare/src/sdk.ts
@@ -1,5 +1,4 @@
-import type { Integration } from '@sentry/core';
-import { getBaseDefaultIntegrations, initWithDefaultIntegrations } from './baseSdk';
+import { initBaseSdk } from './baseSdk';
import type { CloudflareClient, CloudflareOptions } from './client';
import { setupOpenTelemetryTracer } from './opentelemetry/tracer';
import { type RequestHandlerWrapperOptions, wrapRequestHandlerWithInit } from './wrapRequestHandlerWithInit';
@@ -7,12 +6,7 @@ import { type RequestHandlerWrapperOptions, wrapRequestHandlerWithInit } from '.
// Test-only helper, re-exported here so tests can reset the global client cache.
export { _clearGlobalClientCache } from './clientCache';
-/**
- * Get the default integrations for the Cloudflare SDK.
- */
-export function getDefaultIntegrations(options: CloudflareOptions): Integration[] {
- return getBaseDefaultIntegrations(options);
-}
+export { getDefaultIntegrations } from './baseSdk';
/**
* Initializes the cloudflare SDK.
@@ -28,13 +22,12 @@ export function init(options: CloudflareOptions): CloudflareClient | undefined {
setupOpenTelemetryTracer();
}
- return initWithDefaultIntegrations(options, getDefaultIntegrations);
+ return initBaseSdk(options);
}
/**
- * `wrapRequestHandler` backed by `init`, so the request gets the full default integrations and
- * honors `enableOpenTelemetrySetup`. The `@sentry/cloudflare/request` variant deliberately skips
- * both to stay usable without `nodejs_compat`.
+ * `wrapRequestHandler` backed by `init`, so the request honors `enableOpenTelemetrySetup`. The
+ * `@sentry/cloudflare/request` variant deliberately skips it to stay usable without `nodejs_compat`.
*
* For framework SDKs building on the main entry point, e.g. SvelteKit, whose OpenTelemetry spans
* need the tracer provider.
diff --git a/packages/cloudflare/src/vite/index.ts b/packages/cloudflare/src/vite/index.ts
index d6005250d9ee..a0a4b91fecdc 100644
--- a/packages/cloudflare/src/vite/index.ts
+++ b/packages/cloudflare/src/vite/index.ts
@@ -90,6 +90,7 @@ export function sentryCloudflareVitePlugin(options: SentryCloudflareVitePluginOp
// Route the injected `tracingChannel` imports through the workerd-safe façade so a
// dependency wrapped at module scope (`const app = new Hono()`) doesn't throw on workerd.
dcModule: '@sentry/cloudflare/orchestrion-diagnostics-channel',
+ transformOptimizedDeps: true,
}),
sentryMastraObservabilityProviderPlugin(),
sentryFlueRuntimeProviderPlugin(),
diff --git a/packages/cloudflare/src/workflows.ts b/packages/cloudflare/src/workflows.ts
index ec065bca348e..f4869a38615e 100644
--- a/packages/cloudflare/src/workflows.ts
+++ b/packages/cloudflare/src/workflows.ts
@@ -3,6 +3,11 @@ import {
CODE_FUNCTION_NAME,
SENTRY_OP,
SENTRY_ORIGIN,
+ CLOUDFLARE_WORKFLOW_ATTEMPT,
+ CLOUDFLARE_WORKFLOW_RETRIES_BACKOFF,
+ CLOUDFLARE_WORKFLOW_RETRIES_DELAY,
+ CLOUDFLARE_WORKFLOW_RETRIES_LIMIT,
+ CLOUDFLARE_WORKFLOW_TIMEOUT,
} from '@sentry/conventions/attributes';
import { FUNCTION } from '@sentry/conventions/op';
import type { PropagationContext, Scope } from '@sentry/core';
@@ -136,13 +141,13 @@ class WrappedWorkflowStep implements WorkflowStep {
[SENTRY_OP]: FUNCTION,
[CODE_FUNCTION_NAME]: name,
'workflow.step.name': name,
- 'cloudflare.workflow.timeout': config?.timeout,
- 'cloudflare.workflow.retries.backoff': config?.retries?.backoff,
+ [CLOUDFLARE_WORKFLOW_TIMEOUT]: config?.timeout,
+ [CLOUDFLARE_WORKFLOW_RETRIES_BACKOFF]: config?.retries?.backoff,
// In workers-types v5, `delay` may be a `WorkflowDelayFunction`, which isn't a valid span attribute value.
- 'cloudflare.workflow.retries.delay':
+ [CLOUDFLARE_WORKFLOW_RETRIES_DELAY]:
typeof config?.retries?.delay === 'function' ? undefined : config?.retries?.delay,
- 'cloudflare.workflow.retries.limit': config?.retries?.limit,
- 'cloudflare.workflow.attempt': attempt,
+ [CLOUDFLARE_WORKFLOW_RETRIES_LIMIT]: config?.retries?.limit,
+ [CLOUDFLARE_WORKFLOW_ATTEMPT]: attempt,
[SENTRY_ORIGIN]: 'auto.faas.cloudflare.workflow',
[SENTRY_SEGMENT_NAME_SOURCE]: 'task',
},
diff --git a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts
index e852ad1cf661..1f3ddde1dd6d 100644
--- a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts
+++ b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts
@@ -1,37 +1,15 @@
import type { CfProperties, IncomingRequestCfProperties } from '@cloudflare/workers-types';
-import {
- NETWORK_PROTOCOL_NAME,
- NETWORK_PROTOCOL_VERSION,
- SENTRY_OP,
- SENTRY_SEGMENT_NAME_SOURCE,
-} from '@sentry/conventions/attributes';
-import { HTTP_SERVER } from '@sentry/conventions/op';
-import type { Span } from '@sentry/core';
-import {
- captureException,
- continueTrace,
- getHttpSpanDetailsFromUrlObject,
- hasSpanStreamingEnabled,
- httpHeadersToSpanAttributes,
- HTTP_SPAN_NAME_FALLBACK,
- parseStringToURLObject,
- safeCallback,
- setHttpStatus,
- startSpanManual,
- winterCGHeadersToDict,
-} from '@sentry/core';
-import { classifyResponseStreaming } from '@sentry/core/server';
+import { NETWORK_PROTOCOL_NAME, NETWORK_PROTOCOL_VERSION } from '@sentry/conventions/attributes';
+import type { SpanAttributes } from '@sentry/core';
+import { getIsolationScope, safeCallback } from '@sentry/core';
+import { wrapServerRequestHandler } from '@sentry/server-utils/request-handler';
import { DEBUG_BUILD } from './debug-build';
-import {
- captureIncomingRequestBody,
- getIncomingRequestSpanHooks,
- shouldCreateIncomingRequestSpan,
-} from './integrations/httpServer';
+import { getHttpServerRequestOptions } from './integrations/httpServer';
import { flushDeferredChannelEvents } from './orchestrion-deferred-channels';
import type { CloudflareClient, CloudflareOptions } from './client';
import type { ExecutionContextCompat } from './executionContext';
import { flushAndDispose, getOriginalWaitUntil } from './flush';
-import { addCloudResourceContext, addCultureContext, addRequest } from './scope-utils';
+import { addCloudResourceContext, addCultureContext } from './scope-utils';
import { getInvocationState, getInvocationWaitUntil } from './utils/invocationContext';
import { withInvocationIsolationScope } from './utils/invocationScope';
@@ -63,7 +41,7 @@ type InitSdk = (options: CloudflareOptions) => CloudflareClient | undefined;
* Same as {@link wrapRequestHandler}, but with the SDK initialization injected.
*
* Wrappers that are only reachable from the main entry point — where `nodejs_compat` is a
- * requirement anyway — pass `init` from `sdk.ts` to get the full default integrations.
+ * requirement anyway — pass `init` from `sdk.ts` so `enableOpenTelemetrySetup` is honored.
*
* @internal
*/
@@ -85,7 +63,6 @@ export function wrapRequestHandlerWithInit(
: context
? getOriginalWaitUntil(context).bind(context)
: undefined;
- const errorMechanismType = getRequestErrorMechanismType(context);
const client = initSdk({ ...options, ctx: context });
isolationScope.setClient(client);
@@ -95,43 +72,8 @@ export function wrapRequestHandlerWithInit(
// request, where workerd allows publish, and init() above has registered subscribers.
flushDeferredChannelEvents();
- const urlObject = parseStringToURLObject(request.url);
- const [rawName, attributes] = getHttpSpanDetailsFromUrlObject(
- urlObject,
- 'server',
- 'auto.http.cloudflare',
- request,
- undefined,
- 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' || !client || !hasSpanStreamingEnabled(client)
- ? rawName
- : request.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK;
-
- const contentLength = request.headers.get('content-length');
- if (contentLength) {
- attributes['http.request.body.size'] = parseInt(contentLength, 10);
- }
-
- const userAgentHeader = request.headers.get('user-agent');
- if (userAgentHeader) {
- attributes['user_agent.original'] = userAgentHeader;
- }
-
- if (client) {
- Object.assign(
- attributes,
- httpHeadersToSpanAttributes(winterCGHeadersToDict(request.headers), client.getDataCollectionOptions()),
- );
- }
-
- attributes[SENTRY_OP] = HTTP_SERVER;
-
addCloudResourceContext(isolationScope);
- addRequest(isolationScope, request);
+ const attributes: SpanAttributes = {};
if (request.cf) {
addCultureContext(isolationScope, request.cf);
@@ -142,143 +84,50 @@ export function wrapRequestHandlerWithInit(
}
}
- // Do not capture spans for OPTIONS and HEAD requests
- if (request.method === 'OPTIONS' || request.method === 'HEAD') {
+ if (!client) {
try {
return await handler();
- } catch (e) {
- if (captureErrors) {
- captureException(e, { mechanism: { handled: false, type: errorMechanismType } });
- }
- throw e;
} finally {
waitUntil?.(flushAndDispose(client));
}
}
- if (client) {
- await captureIncomingRequestBody(client, request);
- }
-
- const { onSpanCreated, onSpanEnd } = getIncomingRequestSpanHooks(client);
-
- // Runs the handler. Ends the span (if any) and flushes when the response is done.
- const respond = async (span: Span | undefined): Promise => {
- let res: Response;
-
- try {
- res = await handler();
- if (span) {
- setHttpStatus(span, res.status);
- }
-
- // After the handler runs, the span name might have been updated by nested instrumentation
- // (e.g., Remix parameterizing routes). The span should already have the correct name
- // from that instrumentation, so we don't need to do anything here.
- } catch (e) {
- span?.end();
- if (captureErrors) {
- captureException(e, { mechanism: { handled: false, type: errorMechanismType } });
- }
- waitUntil?.(flushAndDispose(client));
- throw e;
- }
-
- let ended = false;
- const endSpan = (): void => {
- if (ended) return;
-
- ended = true;
- if (span) {
- onSpanEnd(span, request, res);
- span.end();
- }
- };
-
- const userClassification = safeCallback(
- DEBUG_BUILD ? 'Error in `isStreamingResponse`, using the default classification:' : '',
- () => options.isStreamingResponse?.(res, request),
- () => undefined,
- );
- // An async callback returns a promise, which would otherwise count as `true`.
- const isStreaming =
- typeof userClassification === 'boolean' ? userClassification : classifyResponseStreaming(res).isStreaming;
-
- if (isStreaming && res.body) {
- try {
- let transformerUsed = false;
-
- const endSpanOnce = (): void => {
- if (ended) return;
-
- endSpan();
+ // Note: The span will not have a duration unless I/O happens in the handler. This is
+ // because of how the cloudflare workers runtime works.
+ // See: https://developers.cloudflare.com/workers/runtime-apis/performance/
+ return wrapServerRequestHandler(
+ {
+ request,
+ client,
+ // Without `AsyncLocalStorage`, `withInvocationIsolationScope` does not fork, and the scope it
+ // passes in is not the current one. The request data has to go on the current scope, where
+ // events captured in the handler read it.
+ isolationScope: getIsolationScope(),
+ options: getHttpServerRequestOptions(client),
+ origin: 'auto.http.cloudflare',
+ mechanism: captureErrors ? { handled: false, type: getRequestErrorMechanismType(context) } : undefined,
+ attributes,
+ isStreamingResponse: response => {
+ const classification = safeCallback(
+ DEBUG_BUILD ? 'Error in `isStreamingResponse`, using the default classification:' : '',
+ () => options.isStreamingResponse?.(response, request),
+ () => undefined,
+ );
+ // An async callback returns a promise, which would otherwise count as `true`.
+ return typeof classification === 'boolean' ? classification : undefined;
+ },
+ onDone: response => {
+ // Don't dispose for protocol upgrades (101 Switching Protocols) - the connection stays alive.
+ // This includes WebSocket upgrades where webSocketMessage/webSocketClose handlers
+ // will still be called and may need the client to capture errors.
+ if (response?.status === 101) {
+ waitUntil?.(client.flush(2000));
+ } else {
waitUntil?.(flushAndDispose(client));
- };
-
- // Workers with a compatibility date before 2022-11-30 and without the
- // `transformstream_enable_standard_constructor` flag (for example under older mini-oxygen
- // releases) ignore the transformer, so `flush` and `cancel` would never end the span. Only a
- // used transformer runs `start`.
- const transform = new TransformStream({
- start() {
- transformerUsed = true;
- },
- flush() {
- // Source stream completed normally.
- endSpanOnce();
- },
- cancel() {
- // Client disconnected (or downstream cancelled). The `cancel`
- // is being called while the response is still considered
- // active, so this is a safe place to end the span.
- endSpanOnce();
- },
- });
-
- if (transformerUsed) {
- // Passing the original response as the init keeps `encodeBody: 'manual'`, which a
- // Response does not expose as a property. Without it, workerd compresses a
- // pre-compressed body a second time.
- return new Response(res.body.pipeThrough(transform), res);
}
- } catch {
- // Falls back to ending the span at handler return.
- }
- }
-
- // Non-streaming response - end span immediately and return original
- endSpan();
-
- // Don't dispose for protocol upgrades (101 Switching Protocols) - the connection stays alive.
- // This includes WebSocket upgrades where webSocketMessage/webSocketClose handlers
- // will still be called and may need the client to capture errors.
- if (res.status === 101) {
- waitUntil?.(client?.flush(2000));
- } else {
- waitUntil?.(flushAndDispose(client));
- }
- return res;
- };
-
- const createSpan = shouldCreateIncomingRequestSpan(client, request);
-
- return continueTrace(
- { sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage') },
- () => {
- if (!createSpan) {
- return respond(undefined);
- }
-
- // Note: This span will not have a duration unless I/O happens in the handler. This is
- // because of how the cloudflare workers runtime works.
- // See: https://developers.cloudflare.com/workers/runtime-apis/performance/
-
- // Use startSpanManual to control when span ends (needed for streaming responses)
- return startSpanManual({ name, attributes }, span => {
- onSpanCreated(span, request);
- return respond(span);
- });
+ },
},
+ () => handler(),
);
}, wrapperOptions.context);
}
diff --git a/packages/cloudflare/test/request-stack-strategy.test.ts b/packages/cloudflare/test/request-stack-strategy.test.ts
new file mode 100644
index 000000000000..3e3cda68c479
--- /dev/null
+++ b/packages/cloudflare/test/request-stack-strategy.test.ts
@@ -0,0 +1,45 @@
+// This file sets no async context strategy, so the SDK uses the stack fallback, as on runtimes
+// without `AsyncLocalStorage` (for example Shopify Oxygen, or Workers without `nodejs_compat`).
+
+import type { Event } from '@sentry/core';
+import * as SentryCore from '@sentry/core';
+import { expect, test, vi } from 'vitest';
+import { httpServerIntegration, wrapRequestHandler } from '../src/request';
+
+test('attaches the request data and body to events captured in the handler without AsyncLocalStorage', async () => {
+ let event: Event | undefined;
+ const body = JSON.stringify({ payload: 'x'.repeat(4_000) });
+
+ await wrapRequestHandler(
+ {
+ options: {
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ cacheClient: false,
+ integrations: [httpServerIntegration({ maxRequestBodySize: 'small' })],
+ beforeSend(captured) {
+ event = captured;
+ return null;
+ },
+ },
+ request: new Request('https://example.com/action', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body,
+ }),
+ context: { waitUntil: vi.fn(), passThroughOnException: vi.fn() } as never,
+ },
+ () => {
+ SentryCore.captureException(new Error('Action Error'));
+ return new Response('error', { status: 500 });
+ },
+ );
+
+ expect(event?.request).toEqual(
+ expect.objectContaining({
+ method: 'POST',
+ url: 'https://example.com/action',
+ // `maxRequestBodySize: 'small'` truncates at 1,000 bytes.
+ data: `${body.slice(0, 997)}...`,
+ }),
+ );
+});
diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts
index 972180c62a8c..d05ad7420d44 100644
--- a/packages/cloudflare/test/request.test.ts
+++ b/packages/cloudflare/test/request.test.ts
@@ -677,6 +677,7 @@ describe('withSentry', () => {
'http.response.status_code': 200,
'http.request.body.size': 10,
'http.request.header.content-length': ['10'],
+ 'http.response.header.content-type': ['text/plain;charset=UTF-8'],
},
op: 'http.server',
origin: 'auto.http.cloudflare',
@@ -1297,13 +1298,16 @@ describe('flushAndDispose', () => {
},
);
- test('tears down at handler return when TransformStream ignores the transformer', async () => {
- // workerd without `transformstream_enable_standard_constructor` returns an identity stream.
+ test('tears down at handler return when the ReadableStream constructor is not available', async () => {
+ const response = new Response('shell
', {
+ headers: { 'content-type': 'text/x-component;charset=utf-8' },
+ });
+ // workerd before the 2022-11-30 compatibility date, without `streams_enable_constructors`.
vi.stubGlobal(
- 'TransformStream',
- class extends TransformStream {
+ 'ReadableStream',
+ class {
constructor() {
- super();
+ throw new TypeError('To use the new ReadableStream() constructor, enable streams_enable_constructors.');
}
},
);
@@ -1316,16 +1320,14 @@ describe('flushAndDispose', () => {
const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true);
- const response = new Response('shell
', {
- headers: { 'content-type': 'text/x-component;charset=utf-8' },
- });
-
const result = await wrapRequestHandler(
{ options, request: new Request('https://example.com'), context },
() => response,
);
expect(result).toBe(response);
+ expect(result.bodyUsed).toBe(false);
+ expect(result.body?.locked).toBe(false);
expect(waitUntil).toHaveBeenCalledTimes(1);
flushSpy.mockRestore();
diff --git a/packages/cloudflare/test/requestModuleGraph.test.ts b/packages/cloudflare/test/requestModuleGraph.test.ts
index 2100f4a47311..8b021862a819 100644
--- a/packages/cloudflare/test/requestModuleGraph.test.ts
+++ b/packages/cloudflare/test/requestModuleGraph.test.ts
@@ -69,14 +69,19 @@ describe('module graph of `wrapRequestHandler`', () => {
it('contains no package that depends on Node.js APIs', () => {
// `@sentry/server-utils` subscribes to `node:diagnostics_channel` and `@sentry/node` needs Node.js
// throughout. Both are only allowed in `sdk.ts`, `index.ts` and `vite/` (see `.oxlintrc.json`).
- expect(externals.filter(specifier => /^@sentry\/(node|server-utils)(\/|$)/.test(specifier))).toEqual([]);
+ // The `request-handler` entry of `@sentry/server-utils` has no Node.js imports.
+ const nodePackages = externals.filter(
+ specifier =>
+ /^@sentry\/(node|server-utils)(\/|$)/.test(specifier) && specifier !== '@sentry/server-utils/request-handler',
+ );
+ expect(nodePackages).toEqual([]);
});
it('does not reach `async.ts`, the only shipped module importing `node:async_hooks`', () => {
expect(modules).not.toContain('async.ts');
});
- it('does not reach `sdk.ts`, which holds the default integrations that need `nodejs_compat`', () => {
+ it('does not reach `sdk.ts`, which imports the OpenTelemetry tracer setup', () => {
expect(modules).not.toContain('sdk.ts');
});
});
diff --git a/packages/core/package.json b/packages/core/package.json
index bea5fcc2cfca..fbf0297102f6 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -92,6 +92,6 @@
"zod": "^4.6.7"
},
"dependencies": {
- "@sentry/conventions": "^0.29.0"
+ "@sentry/conventions": "^0.30.0"
}
}
diff --git a/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts
index 9cc883e8e257..49eaae2b7916 100644
--- a/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts
+++ b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts
@@ -250,6 +250,15 @@ interface SourceMapsOptions {
*/
filesToDeleteAfterUpload?: string | Array;
+ /**
+ * Whether source maps are removed from the build output after they are uploaded to Sentry.
+ *
+ * When unset, the plugin removes only the source maps it turned on itself, that is, when the bundler's source map
+ * setting was not set. `true` also removes source maps you enabled yourself. `false` never removes them; the plugin
+ * still turns on hidden source maps when the setting is not set, so they can be uploaded.
+ */
+ deleteSourcemapsAfterUpload?: boolean;
+
/**
* Hook to rewrite the `sources` field inside the source map before being uploaded to Sentry. Does not modify the actual source map.
*
diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts
index 761a2519d6d3..54f0c2dae0d2 100644
--- a/packages/core/src/client.ts
+++ b/packages/core/src/client.ts
@@ -250,6 +250,9 @@ export abstract class Client {
*/
protected _unhandledSessionStatus: SessionStatus;
+ /** Whether `attachStacktrace` came from the SDK default rather than the user's options. */
+ protected readonly _attachStacktraceIsDefault: boolean;
+
/**
* Initializes this client instance.
*
@@ -271,6 +274,7 @@ export abstract class Client {
this._promiseBuffer = makePromiseBuffer(options.transportOptions?.bufferSize ?? DEFAULT_TRANSPORT_BUFFER_SIZE);
this._dataCollection = resolveDataCollectionOptions(options);
this._unhandledSessionStatus = 'crashed';
+ this._attachStacktraceIsDefault = options.attachStacktrace === undefined;
if (options.dsn) {
this._dsn = makeDsn(options.dsn);
@@ -372,7 +376,14 @@ export abstract class Client {
const eventMessage = isParameterizedString(message) ? message : String(message);
const isMessage = isPrimitive(message);
const promisedEvent = isMessage
- ? this.eventFromMessage(eventMessage, level, hintWithEventId)
+ ? this.eventFromMessage(eventMessage, level, hintWithEventId).then(event => {
+ // Lets Replay keep treating messages as non-errors unless the user opted into stack traces
+ if (event.exception && this._attachStacktraceIsDefault) {
+ event.sdkProcessingMetadata = { ...event.sdkProcessingMetadata, hasDefaultSyntheticStacktrace: true };
+ }
+
+ return event;
+ })
: this.eventFromException(message, hintWithEventId);
this._process(
diff --git a/packages/core/src/exports.ts b/packages/core/src/exports.ts
index 19e9509ea3a0..ce531805a997 100644
--- a/packages/core/src/exports.ts
+++ b/packages/core/src/exports.ts
@@ -10,6 +10,7 @@ import type { Primitive } from './types/misc';
import type { Session, SessionContext } from './types/session';
import type { SeverityLevel } from './types/severity';
import type { User } from './types/user';
+import { filterKeyValueData } from './utils/data-collection/filterKeyValueData';
import { debug } from './utils/debug-logger';
import type { ExclusiveEventHintOrCaptureContext } from './utils/prepareEvent';
import { parseEventHintOrCaptureContext } from './utils/prepareEvent';
@@ -252,12 +253,15 @@ export function startSession(context?: SessionContext): Session {
const { user } = getCombinedScopeData(isolationScope, getCurrentScope());
- // Will fetch userAgent if called from browser sdk
- const { userAgent } = GLOBAL_OBJ.navigator || {};
+ let userAgent = GLOBAL_OBJ.navigator?.userAgent;
+ if (userAgent) {
+ const headerCollection = getClient()?.getDataCollectionOptions().httpHeaders.request ?? true;
+ userAgent = filterKeyValueData({ 'User-Agent': userAgent }, headerCollection)['User-Agent'];
+ }
const session = makeSession({
user,
- ...(userAgent && { userAgent }),
+ userAgent,
...context,
});
diff --git a/packages/core/src/integrations/consola.ts b/packages/core/src/integrations/consola.ts
index 6385d330ec37..2c57c9e44360 100644
--- a/packages/core/src/integrations/consola.ts
+++ b/packages/core/src/integrations/consola.ts
@@ -1,3 +1,4 @@
+import { SENTRY_ORIGIN } from '@sentry/conventions/attributes';
import type { Client } from '../client';
import { getClient } from '../currentScopes';
import { _INTERNAL_captureLog } from '../logs/internal';
@@ -253,7 +254,7 @@ export function createConsolaReporter(options: ConsolaReporterOptions = {}): Con
attributes[key] = normalize(value, normalizeDepth, normalizeMaxBreadth);
}
- attributes['sentry.origin'] = 'auto.log.consola';
+ attributes[SENTRY_ORIGIN] = 'auto.log.consola';
if (tag) {
attributes['consola.tag'] = tag;
diff --git a/packages/core/src/integrations/functiontostring.ts b/packages/core/src/integrations/functiontostring.ts
index c915e55d8c3f..2f3a8ce295d3 100644
--- a/packages/core/src/integrations/functiontostring.ts
+++ b/packages/core/src/integrations/functiontostring.ts
@@ -15,11 +15,15 @@ const _functionToStringIntegration = (() => {
setupOnce() {
// eslint-disable-next-line @typescript-eslint/unbound-method
const originalFunctionToString = Function.prototype.toString;
+ // Bound once so calls neither look up a page-replaceable `apply` nor copy arguments, which native toString ignores
+ const callOriginalFunctionToString = Function.prototype.call.bind(originalFunctionToString) as (
+ thisArg: unknown,
+ ) => string;
// intrinsics (like Function.prototype) might be immutable in some environments
// e.g. Node with --frozen-intrinsics, XS (an embedded JavaScript engine) or SES (a JavaScript proposal)
try {
- Function.prototype.toString = function (this: WrappedFunction, ...args: unknown[]): string {
+ Function.prototype.toString = function (this: WrappedFunction): string {
const originalFunction = getOriginalFunction(this);
let unwrappedFunction: WrappedFunction | undefined;
@@ -33,7 +37,7 @@ const _functionToStringIntegration = (() => {
// `toString` never throws here, so fall back to it to avoid turning harmless introspection into noise.
}
- return originalFunctionToString.apply(unwrappedFunction ?? this, args);
+ return callOriginalFunctionToString(unwrappedFunction ?? this);
};
} catch {
// ignore errors here, just don't patch this
diff --git a/packages/core/src/integrations/http/get-outgoing-span-data.ts b/packages/core/src/integrations/http/get-outgoing-span-data.ts
index 71ca886eb486..8f04fd161bbe 100644
--- a/packages/core/src/integrations/http/get-outgoing-span-data.ts
+++ b/packages/core/src/integrations/http/get-outgoing-span-data.ts
@@ -24,6 +24,7 @@ import {
SERVER_PORT,
URL_FULL,
USER_AGENT_ORIGINAL,
+ HTTP_RESPONSE_STATUS_TEXT,
} from '@sentry/conventions/attributes';
import { HTTP_CLIENT } from '@sentry/conventions/op';
@@ -77,7 +78,7 @@ export function setIncomingResponseSpanData(response: HttpIncomingMessage, span:
[NETWORK_PROTOCOL_NAME]: 'http',
[NETWORK_PROTOCOL_VERSION]: httpVersion,
[NETWORK_TRANSPORT]: transport,
- 'http.response.status_text': statusMessage?.toUpperCase(),
+ [HTTP_RESPONSE_STATUS_TEXT]: statusMessage?.toUpperCase(),
[HTTP_RESPONSE_BODY_SIZE]: getContentLengthFromHeaders(response.headers),
...getSocketAttrs(socket),
});
diff --git a/packages/core/src/integrations/http/server-subscription.ts b/packages/core/src/integrations/http/server-subscription.ts
index d75bd2b10703..af5826fb6c86 100644
--- a/packages/core/src/integrations/http/server-subscription.ts
+++ b/packages/core/src/integrations/http/server-subscription.ts
@@ -67,6 +67,8 @@ import {
URL_SCHEME,
USER_AGENT_ORIGINAL,
SENTRY_ORIGIN,
+ HTTP_REQUEST_BODY_SIZE,
+ HTTP_RESPONSE_STATUS_TEXT,
} from '@sentry/conventions/attributes';
import { HTTP_SERVER } from '@sentry/conventions/op';
import { filterCollectedUrl, filterCollectedUrlQuery } from '../../utils/data-collection/filterCollectedUrl';
@@ -357,7 +359,7 @@ function buildServerSpanWrap(
[USER_AGENT_ORIGINAL]: userAgent,
[URL_SCHEME]: scheme,
[NETWORK_TRANSPORT]: httpVersion?.toUpperCase() === 'QUIC' ? 'udp' : 'tcp',
- 'http.request.body.size': getContentLengthFromHeaders(request.headers),
+ [HTTP_REQUEST_BODY_SIZE]: getContentLengthFromHeaders(request.headers),
...httpHeadersToSpanAttributes(normalizedRequest.headers || {}, dataCollectionOptions),
},
},
@@ -379,7 +381,7 @@ function buildServerSpanWrap(
isEnded = true;
// set attributes that come from the response
span.setAttributes({
- 'http.response.status_text': response.statusMessage?.toUpperCase(),
+ [HTTP_RESPONSE_STATUS_TEXT]: response.statusMessage?.toUpperCase(),
[HTTP_RESPONSE_STATUS_CODE]: response.statusCode,
...httpHeadersToSpanAttributes(headersToDict(response.headers), dataCollectionOptions, 'response'),
});
diff --git a/packages/core/src/integrations/requestdata.ts b/packages/core/src/integrations/requestdata.ts
index 12d9d99d433c..04913990440a 100644
--- a/packages/core/src/integrations/requestdata.ts
+++ b/packages/core/src/integrations/requestdata.ts
@@ -18,7 +18,14 @@ import { getUrlQuery } from '../utils/url';
import { getClientIPAddress } from '../utils/clientIPAddress';
import { ipHeaderNames } from '../vendor/getIpAddress';
import { safeSetSpanJSONAttributes } from '../tracing/spans/captureSpan';
-import { SENTRY_IS_LOCALHOST, URL_FULL, URL_QUERY, USER_IP_ADDRESS } from '@sentry/conventions/attributes';
+import {
+ SENTRY_IS_LOCALHOST,
+ URL_FULL,
+ URL_QUERY,
+ USER_IP_ADDRESS,
+ HTTP_REQUEST_BODY_DATA,
+ HTTP_REQUEST_METHOD,
+} from '@sentry/conventions/attributes';
type RequestDataIncludeOptions = {
cookies?: boolean;
@@ -219,7 +226,7 @@ function addNormalizedRequestDataToSpan(
}
if (requestData.method) {
- attributes['http.request.method'] = requestData.method;
+ attributes[HTTP_REQUEST_METHOD] = requestData.method;
}
if (requestData.query_string) {
@@ -254,7 +261,7 @@ function addNormalizedRequestDataToSpan(
if (requestData.data != null) {
const serialized = typeof requestData.data === 'string' ? requestData.data : JSON.stringify(requestData.data);
if (serialized) {
- safeSetSpanJSONAttributes(span, { 'http.request.body.data': serialized });
+ safeSetSpanJSONAttributes(span, { [HTTP_REQUEST_BODY_DATA]: serialized });
}
}
diff --git a/packages/core/src/integrations/serveTunnel.ts b/packages/core/src/integrations/serveTunnel.ts
index 9ee2d81334c1..3994b35a252d 100644
--- a/packages/core/src/integrations/serveTunnel.ts
+++ b/packages/core/src/integrations/serveTunnel.ts
@@ -16,19 +16,29 @@ const _serveTunnelIntegration = ((options: ServerTunnelOptions = {}) => {
}) satisfies IntegrationFn;
/**
- * Serves the managed tunnel, so a browser `init` with `tunnel: true` sends its envelopes through the app's own origin
- * instead of the Sentry ingest URL, where ad-blockers can't drop them. The meta-framework SDKs answer the tunnel
- * requests from their request hooks.
+ * Lets your server forward the events of a browser SDK set up with `tunnel: true` to Sentry.
*
- * By default the tunnel path is derived from the DSN, under the app's base path, and only envelopes for the server's
- * own DSN are forwarded. A `path` is served as written.
+ * With this integration on the server and `tunnel: true` in the browser `init`, the browser sends its events to your
+ * own server instead of Sentry, where ad-blockers can't drop them, and your server forwards them to Sentry.
+ *
+ * Meta-framework SDKs (e.g. Next.js, Nuxt or SvelteKit) answer the tunnel requests for you. On a standalone server
+ * (e.g. Express or Fastify), also call `handleTunnelRequestIfMatched` in your request handling.
+ *
+ * By default only envelopes for the server's own DSN are forwarded.
*
* @example
* ```js
+ * // Server
* Sentry.init({
* dsn: '__DSN__',
* integrations: [Sentry.serveTunnelIntegration()],
* });
+ *
+ * // Browser
+ * Sentry.init({
+ * dsn: '__DSN__',
+ * tunnel: true,
+ * });
* ```
*/
export const serveTunnelIntegration = defineIntegration(_serveTunnelIntegration);
diff --git a/packages/core/src/integrations/supabase.ts b/packages/core/src/integrations/supabase.ts
index 187659aa6345..16b6f95588b7 100644
--- a/packages/core/src/integrations/supabase.ts
+++ b/packages/core/src/integrations/supabase.ts
@@ -295,6 +295,7 @@ function instrumentAuthOperation(operation: AuthOperationFn, isAdmin = false): A
return startSpanManual(
{
name,
+ onlyIfParent: true,
attributes: {
[SENTRY_ORIGIN]: 'auto.db.supabase',
[SENTRY_OP]: DB,
@@ -479,6 +480,7 @@ function instrumentPostgRESTFilterBuilder(
return startSpanManual(
{
name,
+ onlyIfParent: true,
attributes,
},
span => {
diff --git a/packages/core/src/logs/internal.ts b/packages/core/src/logs/internal.ts
index 64bbabad80e6..652212746a5c 100644
--- a/packages/core/src/logs/internal.ts
+++ b/packages/core/src/logs/internal.ts
@@ -1,3 +1,15 @@
+import {
+ SENTRY_ENVIRONMENT,
+ SENTRY_MESSAGE_TEMPLATE,
+ SENTRY_RELEASE,
+ SENTRY_REPLAY_ID,
+ SENTRY_SDK_NAME,
+ SENTRY_SDK_VERSION,
+ SENTRY_TRACE_PARENT_SPAN_ID,
+ USER_EMAIL,
+ USER_ID,
+ USER_NAME,
+} from '@sentry/conventions/attributes';
import type { Attributes } from '../attributes';
import { serializeAttributes } from '../attributes';
import { getGlobalSingleton } from '../carrier';
@@ -98,16 +110,16 @@ export function _INTERNAL_captureLog(
attributes: scopeAttributes,
} = getCombinedScopeData(getIsolationScope(), currentScope);
- setLogAttribute(processedLogAttributes, 'user.id', id, false);
- setLogAttribute(processedLogAttributes, 'user.email', email, false);
- setLogAttribute(processedLogAttributes, 'user.name', username, false);
+ setLogAttribute(processedLogAttributes, USER_ID, id, false);
+ setLogAttribute(processedLogAttributes, USER_EMAIL, email, false);
+ setLogAttribute(processedLogAttributes, USER_NAME, username, false);
- setLogAttribute(processedLogAttributes, 'sentry.release', release);
- setLogAttribute(processedLogAttributes, 'sentry.environment', environment);
+ setLogAttribute(processedLogAttributes, SENTRY_RELEASE, release);
+ setLogAttribute(processedLogAttributes, SENTRY_ENVIRONMENT, environment);
const { name, version } = client.getSdkMetadata()?.sdk ?? {};
- setLogAttribute(processedLogAttributes, 'sentry.sdk.name', name);
- setLogAttribute(processedLogAttributes, 'sentry.sdk.version', version);
+ setLogAttribute(processedLogAttributes, SENTRY_SDK_NAME, name);
+ setLogAttribute(processedLogAttributes, SENTRY_SDK_VERSION, version);
const replay = client.getIntegrationByName<
Integration & {
@@ -117,7 +129,7 @@ export function _INTERNAL_captureLog(
>('Replay');
const replayId = replay?.getReplayId(true);
- setLogAttribute(processedLogAttributes, 'sentry.replay_id', replayId);
+ setLogAttribute(processedLogAttributes, SENTRY_REPLAY_ID, replayId);
if (replayId && replay?.getRecordingMode() === 'buffer') {
// We send this so we can identify cases where the replayId is attached but the replay itself might not have been sent to Sentry
@@ -128,7 +140,7 @@ export function _INTERNAL_captureLog(
if (isParameterizedString(beforeLogMessage)) {
const { __sentry_template_string__, __sentry_template_values__ = [] } = beforeLogMessage;
if (__sentry_template_values__?.length) {
- processedLogAttributes['sentry.message.template'] = __sentry_template_string__;
+ processedLogAttributes[SENTRY_MESSAGE_TEMPLATE] = __sentry_template_string__;
}
__sentry_template_values__.forEach((param, index) => {
processedLogAttributes[`sentry.message.parameter.${index}`] = param;
@@ -137,7 +149,8 @@ export function _INTERNAL_captureLog(
const span = getActiveSpan(currentScope);
// Add the parent span ID to the log attributes for trace context
- setLogAttribute(processedLogAttributes, 'sentry.trace.parent_span_id', span?.spanContext().spanId);
+ // oxlint-disable-next-line typescript/no-deprecated -- Preserve the existing attribute name.
+ setLogAttribute(processedLogAttributes, SENTRY_TRACE_PARENT_SPAN_ID, span?.spanContext().spanId);
const processedLog = { ...beforeLog, attributes: processedLogAttributes };
diff --git a/packages/core/src/logs/utils.ts b/packages/core/src/logs/utils.ts
index 5f95e0db3aad..af4a1ba71e74 100644
--- a/packages/core/src/logs/utils.ts
+++ b/packages/core/src/logs/utils.ts
@@ -1,3 +1,4 @@
+import { SENTRY_MESSAGE_TEMPLATE } from '@sentry/conventions/attributes';
import { isPrimitive } from '../utils/is';
import { normalize } from '../utils/normalize';
import { GLOBAL_OBJ } from '../utils/worldwide';
@@ -60,7 +61,7 @@ export function createConsoleTemplateAttributes(firstArg: unknown, followingArgs
// Create template with placeholders for each argument
const template = new Array(followingArgs.length).fill('{}').join(' ');
- attributes['sentry.message.template'] = `${firstArg} ${template}`;
+ attributes[SENTRY_MESSAGE_TEMPLATE] = `${firstArg} ${template}`;
// Add each argument as a parameter
followingArgs.forEach((arg, index) => {
diff --git a/packages/core/src/metrics/internal.ts b/packages/core/src/metrics/internal.ts
index 830fb279303d..4d0635522bd6 100644
--- a/packages/core/src/metrics/internal.ts
+++ b/packages/core/src/metrics/internal.ts
@@ -1,3 +1,13 @@
+import {
+ SENTRY_ENVIRONMENT,
+ SENTRY_RELEASE,
+ SENTRY_REPLAY_ID,
+ SENTRY_SDK_NAME,
+ SENTRY_SDK_VERSION,
+ USER_EMAIL,
+ USER_ID,
+ USER_NAME,
+} from '@sentry/conventions/attributes';
import { type RawAttributes, serializeAttributes } from '../attributes';
import { getGlobalSingleton } from '../carrier';
import type { Client } from '../client';
@@ -88,18 +98,18 @@ function _enrichMetricAttributes(beforeMetric: Metric, client: Client, user: Use
};
// Add user attributes
- setMetricAttribute(processedMetricAttributes, 'user.id', user.id, false);
- setMetricAttribute(processedMetricAttributes, 'user.email', user.email, false);
- setMetricAttribute(processedMetricAttributes, 'user.name', user.username, false);
+ setMetricAttribute(processedMetricAttributes, USER_ID, user.id, false);
+ setMetricAttribute(processedMetricAttributes, USER_EMAIL, user.email, false);
+ setMetricAttribute(processedMetricAttributes, USER_NAME, user.username, false);
// Add Sentry metadata
- setMetricAttribute(processedMetricAttributes, 'sentry.release', release);
- setMetricAttribute(processedMetricAttributes, 'sentry.environment', environment);
+ setMetricAttribute(processedMetricAttributes, SENTRY_RELEASE, release);
+ setMetricAttribute(processedMetricAttributes, SENTRY_ENVIRONMENT, environment);
// Add SDK metadata
const { name, version } = client.getSdkMetadata()?.sdk ?? {};
- setMetricAttribute(processedMetricAttributes, 'sentry.sdk.name', name);
- setMetricAttribute(processedMetricAttributes, 'sentry.sdk.version', version);
+ setMetricAttribute(processedMetricAttributes, SENTRY_SDK_NAME, name);
+ setMetricAttribute(processedMetricAttributes, SENTRY_SDK_VERSION, version);
// Add replay metadata
const replay = client.getIntegrationByName<
@@ -110,7 +120,7 @@ function _enrichMetricAttributes(beforeMetric: Metric, client: Client, user: Use
>('Replay');
const replayId = replay?.getReplayId(true);
- setMetricAttribute(processedMetricAttributes, 'sentry.replay_id', replayId);
+ setMetricAttribute(processedMetricAttributes, SENTRY_REPLAY_ID, replayId);
if (replayId && replay?.getRecordingMode() === 'buffer') {
setMetricAttribute(processedMetricAttributes, 'sentry._internal.replay_is_buffering', true);
diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts
index 1a2a1761854a..7130bc5eb0d2 100644
--- a/packages/core/src/scope.ts
+++ b/packages/core/src/scope.ts
@@ -67,6 +67,11 @@ export interface SdkProcessingMetadata {
*/
spanCountBeforeProcessing?: number;
ipAddress?: string;
+ /**
+ * Set on `captureMessage` events whose stack trace exists only because `attachStacktrace`
+ * defaults to `true`, so Replay does not treat them as errors in buffer mode.
+ */
+ hasDefaultSyntheticStacktrace?: boolean;
}
/**
diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts
index 9dcc4512bea4..ab9729f60732 100644
--- a/packages/core/src/server-runtime-client.ts
+++ b/packages/core/src/server-runtime-client.ts
@@ -1,3 +1,4 @@
+import { SERVER_ADDRESS } from '@sentry/conventions/attributes';
import { createCheckInEnvelope } from './checkin';
import { Client } from './client';
import { getIsolationScope } from './currentScopes';
@@ -248,7 +249,7 @@ export class ServerRuntimeClient<
this.on('processMetric', metric => {
if (this._options.serverName) {
metric.attributes = {
- 'server.address': this._options.serverName,
+ [SERVER_ADDRESS]: this._options.serverName,
...metric.attributes,
};
}
diff --git a/packages/core/src/tracing/spans/scopeContextAttributes.ts b/packages/core/src/tracing/spans/scopeContextAttributes.ts
index 63b468fea922..a0bc65e4cc90 100644
--- a/packages/core/src/tracing/spans/scopeContextAttributes.ts
+++ b/packages/core/src/tracing/spans/scopeContextAttributes.ts
@@ -1,3 +1,14 @@
+import {
+ ANGULAR_VERSION,
+ CULTURE_LOCALE,
+ CULTURE_TIMEZONE,
+ HTTP_RESPONSE_BODY_SIZE,
+ HTTP_RESPONSE_STATUS_CODE,
+ REACT_VERSION,
+ SENTRY_PROFILER_ID,
+ SENTRY_PROFILE_ID,
+ STATE_TYPE,
+} from '@sentry/conventions/attributes';
import type { Contexts } from '../../types/context';
/**
@@ -13,19 +24,19 @@ export function scopeContextsToSpanAttributes(contexts: Contexts): Record {
- if (isError(childError)) {
+ if (newExceptions.length < limit + 1 && isError(childError)) {
applyExceptionGroupFieldsForParentException(exception, exceptionId, error);
const newException = exceptionFromErrorImplementation(parser, childError);
const newExceptionId = newExceptions.length;
@@ -102,7 +102,7 @@ function aggregateExceptionsFromError(
if (isSuppressedError(error)) {
['error', 'suppressed'].forEach(property => {
const childError = error[property];
- if (isError(childError)) {
+ if (newExceptions.length < limit + 1 && isError(childError)) {
applyExceptionGroupFieldsForParentException(exception, exceptionId, error);
const newException = exceptionFromErrorImplementation(parser, childError);
const newExceptionId = newExceptions.length;
diff --git a/packages/core/src/utils/timestampSequence.ts b/packages/core/src/utils/timestampSequence.ts
index d2755d7a0724..2d2fdfc5f5d0 100644
--- a/packages/core/src/utils/timestampSequence.ts
+++ b/packages/core/src/utils/timestampSequence.ts
@@ -1,4 +1,4 @@
-const SEQUENCE_ATTR_KEY = 'sentry.timestamp.sequence';
+import { SENTRY_TIMESTAMP_SEQUENCE } from '@sentry/conventions/attributes';
let _sequenceNumber = 0;
let _previousTimestampMs: number | undefined;
@@ -27,7 +27,7 @@ export function getSequenceAttribute(timestampInSeconds: number): {
_previousTimestampMs = nowMs;
return {
- key: SEQUENCE_ATTR_KEY,
+ key: SENTRY_TIMESTAMP_SEQUENCE,
value: { value, type: 'integer' },
};
}
diff --git a/packages/core/src/utils/transactionEvent.ts b/packages/core/src/utils/transactionEvent.ts
index 97bf3580ce8c..4a4fe979ab05 100644
--- a/packages/core/src/utils/transactionEvent.ts
+++ b/packages/core/src/utils/transactionEvent.ts
@@ -6,7 +6,7 @@ import type { SpanJSON } from '../types/span';
* Converts a transaction event to a span JSON object.
*/
export function convertTransactionEventToSpanJson(event: TransactionEvent): SpanJSON {
- const { trace_id, parent_span_id, span_id, status, origin, data, op } = event.contexts?.trace ?? {};
+ const { trace_id, parent_span_id, span_id, status, origin, data, op, links } = event.contexts?.trace ?? {};
return {
data: data ?? {},
@@ -19,6 +19,7 @@ export function convertTransactionEventToSpanJson(event: TransactionEvent): Span
timestamp: event.timestamp,
trace_id: trace_id ?? '',
origin,
+ links,
profile_id: data?.[SENTRY_PROFILE_ID] as string | undefined,
exclusive_time: data?.[SENTRY_EXCLUSIVE_TIME] as number | undefined,
measurements: event.measurements,
@@ -43,6 +44,7 @@ export function convertSpanJsonToTransactionEvent(span: SpanJSON): TransactionEv
op: span.op,
status: span.status,
origin: span.origin,
+ links: span.links,
data: {
...span.data,
...(span.profile_id && { [SENTRY_PROFILE_ID]: span.profile_id }),
diff --git a/packages/core/src/utils/tunnelForward.ts b/packages/core/src/utils/tunnelForward.ts
index e30d7e21bfe4..01f2af0947bd 100644
--- a/packages/core/src/utils/tunnelForward.ts
+++ b/packages/core/src/utils/tunnelForward.ts
@@ -62,7 +62,9 @@ export function handleTunnelRequest(
options?: TunnelForwardOptions,
): Promise;
/**
- * The released form, kept until v12. Its `request` option is deprecated: pass the request as the first argument.
+ * The released form, kept until v12.
+ *
+ * @deprecated Pass the request as the first argument instead: `handleTunnelRequest(request, options)`.
*/
export function handleTunnelRequest(options: HandleTunnelRequestOptions): Promise;
export function handleTunnelRequest(
diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts
index fce65f946b89..5d06b33e40d7 100644
--- a/packages/core/test/lib/client.test.ts
+++ b/packages/core/test/lib/client.test.ts
@@ -9,6 +9,7 @@ import {
lastEventId,
linkedErrorsIntegration,
makeSession,
+ resolvedSyncPromise,
Scope,
setCurrentClient,
SyncPromise,
@@ -664,6 +665,44 @@ describe('Client', () => {
);
});
+ test.each([
+ ['left at its default', {}, expect.objectContaining({ hasDefaultSyntheticStacktrace: true })],
+ [
+ 'explicitly enabled',
+ { attachStacktrace: true },
+ expect.not.objectContaining({ hasDefaultSyntheticStacktrace: true }),
+ ],
+ ])('marks message stack traces only when attachStacktrace is %s', (_, attachStacktraceOptions, metadataMatcher) => {
+ const client = new TestClient(getDefaultTestClientOptions({ dsn: PUBLIC_DSN, ...attachStacktraceOptions }));
+ vi.spyOn(client, 'eventFromMessage').mockReturnValue(
+ resolvedSyncPromise({ message: 'test message', exception: { values: [{ value: 'test message' }] } }),
+ );
+ const processor = vi.fn((event: Event) => event);
+ client.addEventProcessor(processor);
+
+ client.captureMessage('test message');
+
+ expect(processor).toHaveBeenCalledWith(
+ expect.objectContaining({ sdkProcessingMetadata: metadataMatcher }),
+ expect.anything(),
+ );
+ });
+
+ test('does not mark message events without a stack trace', () => {
+ const client = new TestClient(getDefaultTestClientOptions({ dsn: PUBLIC_DSN }));
+ const processor = vi.fn((event: Event) => event);
+ client.addEventProcessor(processor);
+
+ client.captureMessage('test message');
+
+ expect(processor).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sdkProcessingMetadata: expect.not.objectContaining({ hasDefaultSyntheticStacktrace: true }),
+ }),
+ expect.anything(),
+ );
+ });
+
test('sets the correct lastEventId', () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN });
const client = new TestClient(options);
@@ -1597,6 +1636,37 @@ describe('Client', () => {
});
});
+ test('keeps root span links on the sent transaction with `beforeSendSpan`', () => {
+ const beforeSendSpan = withStaticSpan(vi.fn((span: SpanJSON) => span));
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSendSpan });
+ const client = new TestClient(options);
+
+ const links = [
+ {
+ trace_id: '12312012123120121231201212312012',
+ span_id: '1231201212312012',
+ sampled: true,
+ attributes: { 'sentry.link.type': 'previous_trace' },
+ },
+ ];
+
+ client.captureEvent({
+ transaction: '/animals/are/great',
+ type: 'transaction',
+ spans: [],
+ contexts: {
+ trace: {
+ span_id: '9e15bf99fbe4bc80',
+ trace_id: '86f39e84263a4de99c326acab3bfe3bd',
+ links,
+ },
+ },
+ });
+
+ expect(beforeSendSpan).toHaveBeenCalledTimes(1);
+ expect(TestClient.instance!.event!.contexts?.trace?.links).toEqual(links);
+ });
+
test('calls `beforeSendTransaction` and uses the modified event', () => {
expect.assertions(2);
diff --git a/packages/core/test/lib/integrations/functiontostring.test.ts b/packages/core/test/lib/integrations/functiontostring.test.ts
index 99b54fa1c3ff..e42c83a77611 100644
--- a/packages/core/test/lib/integrations/functiontostring.test.ts
+++ b/packages/core/test/lib/integrations/functiontostring.test.ts
@@ -84,6 +84,42 @@ describe('FunctionToString', () => {
expect(inspectedFunction.toString()).toBe(expected);
});
+ it('does not recurse when Function.prototype.apply performs a function toString check', () => {
+ function inspectedFunction(): void {}
+
+ const fts = functionToStringIntegration();
+ getClient()?.addIntegration(fts);
+ const expected = inspectedFunction.toString();
+
+ const applySpy = vi.spyOn(Function.prototype, 'apply').mockImplementation(function (
+ this: Function,
+ thisArg: unknown,
+ args?: unknown[],
+ ) {
+ this.toString();
+ return Reflect.apply(this, thisArg, args ?? []);
+ });
+ let result: unknown;
+ try {
+ result = inspectedFunction.toString();
+ } catch (error) {
+ result = error;
+ }
+ applySpy.mockRestore();
+
+ expect(result).toBe(expected);
+ });
+
+ it('does not overflow the stack when called with many arguments', () => {
+ function inspectedFunction(): void {}
+
+ const fts = functionToStringIntegration();
+ getClient()?.addIntegration(fts);
+ const expected = inspectedFunction.toString();
+
+ expect(Reflect.apply(Function.prototype.toString, inspectedFunction, new Array(100_000))).toBe(expected);
+ });
+
it('falls back to native toString and does not throw when the carrier read throws', () => {
const foo = {
bar(wat: boolean): boolean {
diff --git a/packages/core/test/lib/integrations/supabase.test.ts b/packages/core/test/lib/integrations/supabase.test.ts
index cae6111f7e4d..dcb0233abdb0 100644
--- a/packages/core/test/lib/integrations/supabase.test.ts
+++ b/packages/core/test/lib/integrations/supabase.test.ts
@@ -645,4 +645,42 @@ describe('Supabase Integration', () => {
expect(spanOptions.attributes['db.operation.name']).toBe('upsert');
});
});
+
+ describe('onlyIfParent', () => {
+ beforeEach(() => {
+ vi.spyOn(exportsModule, 'captureException').mockImplementation(() => '');
+ vi.spyOn(breadcrumbModule, 'addBreadcrumb').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('starts the PostgREST span with onlyIfParent so it is dropped without an active parent', async () => {
+ tracingMocks.startSpanManual.mockClear();
+ const client = createMockSupabaseClient({ status: 200, data: [] });
+ instrumentSupabaseClient(client);
+
+ await (client as any).from('todos').select('*').then();
+
+ expect(tracingMocks.startSpanManual).toHaveBeenCalledWith(
+ expect.objectContaining({ onlyIfParent: true }),
+ expect.any(Function),
+ );
+ });
+
+ it('starts the auth span with onlyIfParent so it is dropped without an active parent', async () => {
+ tracingMocks.startSpanManual.mockClear();
+ const client = createMockSupabaseClient(undefined) as any;
+ client.auth.signInWithPassword = vi.fn(() => Promise.resolve({ data: { user: {} }, error: null }));
+ instrumentSupabaseClient(client);
+
+ await client.auth.signInWithPassword({ email: 'a@b.c', password: 'pw' });
+
+ expect(tracingMocks.startSpanManual).toHaveBeenCalledWith(
+ expect.objectContaining({ onlyIfParent: true }),
+ expect.any(Function),
+ );
+ });
+ });
});
diff --git a/packages/core/test/lib/session.test.ts b/packages/core/test/lib/session.test.ts
index dfe3a764af16..f23c89e5231d 100644
--- a/packages/core/test/lib/session.test.ts
+++ b/packages/core/test/lib/session.test.ts
@@ -1,7 +1,40 @@
-import { describe, expect, it, test } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest';
+import { getCurrentScope } from '../../src/currentScopes';
+import { startSession } from '../../src/exports';
import { closeSession, makeSession, updateSession } from '../../src/session';
import type { SessionContext } from '../../src/types/session';
import { timestampInSeconds } from '../../src/utils/time';
+import { getDefaultTestClientOptions, TestClient } from '../mocks/client';
+import { resetGlobals } from '../testutils';
+
+describe('startSession', () => {
+ const userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/130.0.0.0 Safari/537.36';
+
+ beforeEach(() => {
+ resetGlobals();
+ vi.stubGlobal('navigator', { userAgent });
+ getCurrentScope().setClient(
+ new TestClient(getDefaultTestClientOptions({ dataCollection: { httpHeaders: { request: false } } })),
+ );
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ resetGlobals();
+ });
+
+ it('omits the automatic user agent when request header collection is disabled', () => {
+ const session = startSession();
+
+ expect(session.toJSON().attrs?.user_agent).toBeUndefined();
+ });
+
+ it('preserves an explicit user agent when request header collection is disabled', () => {
+ const session = startSession({ userAgent: 'custom-user-agent' });
+
+ expect(session.toJSON().attrs?.user_agent).toBe('custom-user-agent');
+ });
+});
describe('Session', () => {
it('initializes with the proper defaults', () => {
diff --git a/packages/core/test/lib/utils/aggregate-errors.test.ts b/packages/core/test/lib/utils/aggregate-errors.test.ts
index 2ca4891710a9..34498347cf31 100644
--- a/packages/core/test/lib/utils/aggregate-errors.test.ts
+++ b/packages/core/test/lib/utils/aggregate-errors.test.ts
@@ -273,6 +273,50 @@ describe('applyAggregateErrorsToEvent()', () => {
});
});
+ test('limits AggregateError children', () => {
+ const error = new FakeAggregateError(
+ Array.from({ length: 20 }, (_, i) => new Error(`child-${i}`)),
+ 'group',
+ );
+ const event: Event = { exception: { values: [exceptionFromError(stackParser, error)] } };
+
+ applyAggregateErrorsToEvent(exceptionFromError, stackParser, 'cause', 5, event, { originalException: error });
+
+ expect(event.exception?.values?.map(exception => exception.value)).toEqual([
+ 'child-4',
+ 'child-3',
+ 'child-2',
+ 'child-1',
+ 'child-0',
+ 'group',
+ ]);
+ });
+
+ test('limits SuppressedError siblings after traversing a nested error', () => {
+ const inner = new FakeSuppressedError(new Error('dispose failed'), new Error('original'), 'inner');
+ const error = new FakeSuppressedError(inner, new Error('outer suppressed'), 'outer');
+ const event: Event = { exception: { values: [exceptionFromError(stackParser, error)] } };
+
+ applyAggregateErrorsToEvent(exceptionFromError, stackParser, 'cause', 2, event, { originalException: error });
+
+ expect(event.exception?.values?.map(exception => exception.value)).toEqual(['dispose failed', 'inner', 'outer']);
+ });
+
+ test('shares the limit across causes, SuppressedErrors, and AggregateErrors', () => {
+ const cause = new FakeSuppressedError(new Error('dispose failed'), new Error('original'), 'cause');
+ const error = new CustomAggregateError([new Error('child')], 'group', cause);
+ const event: Event = { exception: { values: [exceptionFromError(stackParser, error)] } };
+
+ applyAggregateErrorsToEvent(exceptionFromError, stackParser, 'cause', 3, event, { originalException: error });
+
+ expect(event.exception?.values?.map(exception => exception.value)).toEqual([
+ 'original',
+ 'dispose failed',
+ 'cause',
+ 'group',
+ ]);
+ });
+
test('should keep the original mechanism type for the root exception', () => {
const fakeAggregateError = new FakeAggregateError(
[new Error('Nested Error 1'), new Error('Nested Error 2')],
diff --git a/packages/core/test/lib/utils/transactionEvent.test.ts b/packages/core/test/lib/utils/transactionEvent.test.ts
index 03f1e231d975..062ec8aa41dc 100644
--- a/packages/core/test/lib/utils/transactionEvent.test.ts
+++ b/packages/core/test/lib/utils/transactionEvent.test.ts
@@ -48,6 +48,14 @@ describe('convertTransactionEventToSpanJson', () => {
[SENTRY_EXCLUSIVE_TIME]: 123.45,
other: 'value',
},
+ links: [
+ {
+ span_id: 'linked-span',
+ trace_id: 'linked-trace',
+ sampled: true,
+ attributes: { 'sentry.link.type': 'previous_trace' },
+ },
+ ],
},
},
start_timestamp: 1234567800,
@@ -74,6 +82,14 @@ describe('convertTransactionEventToSpanJson', () => {
origin: 'manual',
profile_id: 'profile123',
exclusive_time: 123.45,
+ links: [
+ {
+ span_id: 'linked-span',
+ trace_id: 'linked-trace',
+ sampled: true,
+ attributes: { 'sentry.link.type': 'previous_trace' },
+ },
+ ],
measurements: {
fp: { value: 123, unit: 'millisecond' },
},
@@ -142,6 +158,14 @@ describe('convertSpanJsonToTransactionEvent', () => {
origin: 'manual',
profile_id: 'profile123',
exclusive_time: 123.45,
+ links: [
+ {
+ span_id: 'linked-span',
+ trace_id: 'linked-trace',
+ sampled: true,
+ attributes: { 'sentry.link.type': 'previous_trace' },
+ },
+ ],
measurements: {
fp: { value: 123, unit: 'millisecond' },
},
@@ -160,6 +184,14 @@ describe('convertSpanJsonToTransactionEvent', () => {
op: 'http',
status: 'ok',
origin: 'manual',
+ links: [
+ {
+ span_id: 'linked-span',
+ trace_id: 'linked-trace',
+ sampled: true,
+ attributes: { 'sentry.link.type': 'previous_trace' },
+ },
+ ],
data: {
other: 'value',
[SENTRY_PROFILE_ID]: 'profile123',
diff --git a/packages/deno/package.json b/packages/deno/package.json
index 4c87ac6bd377..d81a32675fe1 100644
--- a/packages/deno/package.json
+++ b/packages/deno/package.json
@@ -28,7 +28,7 @@
],
"dependencies": {
"@opentelemetry/api": "^1.9.1",
- "@sentry/conventions": "^0.29.0",
+ "@sentry/conventions": "^0.30.0",
"@sentry/core": "11.10.0",
"@sentry/server-runtime-injection": "11.10.0",
"@sentry/server-utils": "11.10.0"
diff --git a/packages/deno/src/client.ts b/packages/deno/src/client.ts
index 8dbbd144f9c7..20207bdc0074 100644
--- a/packages/deno/src/client.ts
+++ b/packages/deno/src/client.ts
@@ -1,3 +1,4 @@
+import { SERVER_ADDRESS } from '@sentry/conventions/attributes';
import type { ServerRuntimeClientOptions } from '@sentry/core/server';
import { SDK_VERSION } from '@sentry/core';
import { ServerRuntimeClient } from '@sentry/core/server';
@@ -65,7 +66,7 @@ export class DenoClient extends ServerRuntimeClient {
this.on('beforeCaptureLog', log => {
log.attributes = {
...log.attributes,
- 'server.address': serverName,
+ [SERVER_ADDRESS]: serverName,
};
});
}
diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts
index d26507f6988a..4d5fb75365e7 100644
--- a/packages/deno/src/index.ts
+++ b/packages/deno/src/index.ts
@@ -195,6 +195,9 @@ export {
postgresIntegration,
postgresJsIntegration,
neonIntegration,
+ cronJobIntegration,
+ nodeCronIntegration,
+ nodeScheduleIntegration,
tediousIntegration,
eveConversationHook,
eveIntegration,
diff --git a/packages/deno/src/integrations/context.ts b/packages/deno/src/integrations/context.ts
index 8adf1d7dce73..d08362250b58 100644
--- a/packages/deno/src/integrations/context.ts
+++ b/packages/deno/src/integrations/context.ts
@@ -1,3 +1,12 @@
+import {
+ APP_START_TIME,
+ DEVICE_ARCHS,
+ DEVICE_PROCESSOR_COUNT,
+ OS_NAME,
+ OS_VERSION,
+ PROCESS_RUNTIME_ENGINE_NAME,
+ PROCESS_RUNTIME_ENGINE_VERSION,
+} from '@sentry/conventions/attributes';
import type { IntegrationFn } from '@sentry/core';
import { defineIntegration, safeSetSpanJSONAttributes } from '@sentry/core';
@@ -40,20 +49,20 @@ const _denoContextIntegration = (() => {
};
const cachedSpanAttributes: Record = {
- 'app.start_time': appStartTime,
+ [APP_START_TIME]: appStartTime,
// Convention uses 'device.archs' (string[]), but array attributes are not yet serialized.
// Once array serialization lands, this will start appearing on spans automatically.
- 'device.archs': [arch],
- 'device.processor_count': processorCount,
- 'os.name': osName,
- 'process.runtime.engine.name': 'v8',
- 'process.runtime.engine.version': v8Version,
+ [DEVICE_ARCHS]: [arch],
+ [DEVICE_PROCESSOR_COUNT]: processorCount,
+ [OS_NAME]: osName,
+ [PROCESS_RUNTIME_ENGINE_NAME]: 'v8',
+ [PROCESS_RUNTIME_ENGINE_VERSION]: v8Version,
};
getOSRelease()
.then(release => {
cachedContext.os.version = release;
- cachedSpanAttributes['os.version'] = release;
+ cachedSpanAttributes[OS_VERSION] = release;
})
.catch(() => {
// Ignore - os.version will be undefined
diff --git a/packages/deno/src/integrations/denoRuntimeMetrics.ts b/packages/deno/src/integrations/denoRuntimeMetrics.ts
index 0a0382e067b9..d697e898c9be 100644
--- a/packages/deno/src/integrations/denoRuntimeMetrics.ts
+++ b/packages/deno/src/integrations/denoRuntimeMetrics.ts
@@ -1,3 +1,4 @@
+import { SENTRY_ORIGIN } from '@sentry/conventions/attributes';
import { _INTERNAL_safeDateNow, defineIntegration, metrics } from '@sentry/core';
const INTEGRATION_NAME = 'DenoRuntimeMetrics' as const;
@@ -79,8 +80,8 @@ export const denoRuntimeMetricsIntegration = defineIntegration((options: DenoRun
let intervalId: number | undefined;
let prevFlushTime: number = 0;
- const METRIC_ATTRIBUTES_BYTE = { unit: 'byte', attributes: { 'sentry.origin': 'auto.deno.runtime_metrics' } };
- const METRIC_ATTRIBUTES_SECOND = { unit: 'second', attributes: { 'sentry.origin': 'auto.deno.runtime_metrics' } };
+ const METRIC_ATTRIBUTES_BYTE = { unit: 'byte', attributes: { [SENTRY_ORIGIN]: 'auto.deno.runtime_metrics' } };
+ const METRIC_ATTRIBUTES_SECOND = { unit: 'second', attributes: { [SENTRY_ORIGIN]: 'auto.deno.runtime_metrics' } };
function collectMetrics(): void {
const now = _INTERNAL_safeDateNow();
diff --git a/packages/deno/src/wrap-deno-request-handler.ts b/packages/deno/src/wrap-deno-request-handler.ts
index 4d24405d58e4..e050c5523a2d 100644
--- a/packages/deno/src/wrap-deno-request-handler.ts
+++ b/packages/deno/src/wrap-deno-request-handler.ts
@@ -1,7 +1,7 @@
import type { Client } from '@sentry/core';
import { debug, getClient, withIsolationScope } from '@sentry/core';
import type { ServerRequestOptions } from '@sentry/core/server';
-import { wrapServerRequestHandler } from '@sentry/server-utils';
+import { wrapServerRequestHandler } from '@sentry/server-utils/request-handler';
export type RequestHandlerWrapperOptions = {
request: Request;
diff --git a/packages/effect/package.json b/packages/effect/package.json
index 310090638907..2ee94342af5b 100644
--- a/packages/effect/package.json
+++ b/packages/effect/package.json
@@ -45,7 +45,7 @@
},
"dependencies": {
"@sentry/browser": "11.10.0",
- "@sentry/conventions": "^0.29.0",
+ "@sentry/conventions": "^0.30.0",
"@sentry/core": "11.10.0",
"@sentry/node": "11.10.0"
},
diff --git a/packages/effect/test/errorReporter.test.ts b/packages/effect/test/errorReporter.test.ts
index ca5c6b5e97e3..62e6cb346708 100644
--- a/packages/effect/test/errorReporter.test.ts
+++ b/packages/effect/test/errorReporter.test.ts
@@ -140,10 +140,17 @@ describe.each([
yield* flush;
expect(events).toHaveLength(2);
- expect(events[0]?.level).toBe('warning');
- expect(events[0]?.exception?.values?.[0]?.type).toBe('RateLimitError');
- expect(events[1]?.level).toBe('fatal');
- expect(events[1]?.exception?.values?.[0]?.type).toBe('FatalError');
+ expect(
+ events.map(event => ({
+ type: event.exception?.values?.[0]?.type,
+ level: event.level,
+ })),
+ ).toEqual(
+ expect.arrayContaining([
+ { type: 'RateLimitError', level: 'warning' },
+ { type: 'FatalError', level: 'fatal' },
+ ]),
+ );
}).pipe(Effect.provide(makeLayer())),
);
diff --git a/packages/elysia/package.json b/packages/elysia/package.json
index 87aaf397a86d..f0b4e238faa4 100644
--- a/packages/elysia/package.json
+++ b/packages/elysia/package.json
@@ -34,7 +34,7 @@
"dependencies": {
"@sentry/bun": "11.10.0",
"@sentry/core": "11.10.0",
- "@sentry/conventions": "^0.29.0"
+ "@sentry/conventions": "^0.30.0"
},
"peerDependencies": {
"elysia": "^1.4.0"
diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts
index abe6212f2728..107545f0cb43 100644
--- a/packages/elysia/src/index.ts
+++ b/packages/elysia/src/index.ts
@@ -117,6 +117,9 @@ export {
// oxlint-disable-next-line typescript/no-deprecated
setupKoaErrorHandler,
genericPoolIntegration,
+ cronJobIntegration,
+ nodeCronIntegration,
+ nodeScheduleIntegration,
graphqlIntegration,
knexIntegration,
kafkaIntegration,
diff --git a/packages/ember/package.json b/packages/ember/package.json
index e099f4e2f48f..4b8193bb52ca 100644
--- a/packages/ember/package.json
+++ b/packages/ember/package.json
@@ -42,17 +42,17 @@
"yalc:publish": "yalc publish --push --sig"
},
"dependencies": {
- "@embroider/addon-shim": "^1.10.2",
+ "@embroider/addon-shim": "^1.11.0",
"@embroider/macros": "^1.20.2",
"@sentry/browser": "11.10.0",
- "@sentry/conventions": "^0.29.0",
+ "@sentry/conventions": "^0.30.0",
"@sentry/core": "11.10.0",
"decorator-transforms": "^2.3.2"
},
"devDependencies": {
- "@babel/core": "^7.29.0",
+ "@babel/core": "^8.1.0",
"@babel/eslint-parser": "^7.28.6",
- "@babel/plugin-transform-typescript": "^7.28.6",
+ "@babel/plugin-transform-typescript": "^8.0.6",
"@ember/app-tsconfig": "^2.0.0",
"@ember/library-tsconfig": "^2.0.0",
"@embroider/addon-dev": "^8.3.0",
@@ -63,7 +63,7 @@
"@rollup/plugin-babel": "^7.0.0",
"ember-source": "^6.12.0",
"typescript": "~5.8.0",
- "vite": "^8.3.1"
+ "vite": "^8.3.5"
},
"engines": {
"node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json
index 53322962f1ae..4f0e474fe884 100644
--- a/packages/gatsby/package.json
+++ b/packages/gatsby/package.json
@@ -51,7 +51,7 @@
"@testing-library/react": "^15.0.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "webpack": "^5.0.0"
+ "webpack": "^5.112.2"
},
"scripts": {
"build": "run-p build:transpile build:types",
diff --git a/packages/google-cloud-serverless/package.json b/packages/google-cloud-serverless/package.json
index 4373cace80f7..3bb8cebcc31c 100644
--- a/packages/google-cloud-serverless/package.json
+++ b/packages/google-cloud-serverless/package.json
@@ -36,7 +36,7 @@
"access": "public"
},
"dependencies": {
- "@sentry/conventions": "^0.29.0",
+ "@sentry/conventions": "^0.30.0",
"@sentry/core": "11.10.0",
"@sentry/node": "11.10.0",
"@sentry/server-runtime-injection": "11.10.0",
diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts
index cd588df14aae..107674b83b95 100644
--- a/packages/google-cloud-serverless/src/index.ts
+++ b/packages/google-cloud-serverless/src/index.ts
@@ -78,6 +78,7 @@ export {
type NodeRuntimeMetricsOptions,
contextLinesIntegration,
nodeContextIntegration,
+ awsLambdaWebIntegration,
localVariablesIntegration,
requestDataIntegration,
fsIntegration,
@@ -118,6 +119,9 @@ export {
fastifyIntegration,
firebaseIntegration,
genericPoolIntegration,
+ cronJobIntegration,
+ nodeCronIntegration,
+ nodeScheduleIntegration,
graphqlIntegration,
knexIntegration,
kafkaIntegration,
@@ -168,6 +172,7 @@ export {
zodErrorsIntegration,
profiler,
amqplibIntegration,
+ BullMQTelemetry,
anthropicAIIntegration,
googleGenAIIntegration,
childProcessIntegration,
diff --git a/packages/google-cloud-serverless/src/integrations/gcp-context.ts b/packages/google-cloud-serverless/src/integrations/gcp-context.ts
index d7a68b371a68..594d66ac4126 100644
--- a/packages/google-cloud-serverless/src/integrations/gcp-context.ts
+++ b/packages/google-cloud-serverless/src/integrations/gcp-context.ts
@@ -1,16 +1,27 @@
+import {
+ GCP_FUNCTION_CONTEXT_EVENT_ID,
+ GCP_FUNCTION_CONTEXT_EVENT_TYPE,
+ GCP_FUNCTION_CONTEXT_ID,
+ GCP_FUNCTION_CONTEXT_RESOURCE,
+ GCP_FUNCTION_CONTEXT_SOURCE,
+ GCP_FUNCTION_CONTEXT_SPECVERSION,
+ GCP_FUNCTION_CONTEXT_TIME,
+ GCP_FUNCTION_CONTEXT_TIMESTAMP,
+ GCP_FUNCTION_CONTEXT_TYPE,
+} from '@sentry/conventions/attributes';
import type { IntegrationFn } from '@sentry/core';
import { defineIntegration, getCurrentScope, safeSetSpanJSONAttributes } from '@sentry/core';
const GCP_CONTEXT_ATTRIBUTE_MAP: Record = {
- type: 'gcp.function.context.type',
- source: 'gcp.function.context.source',
- id: 'gcp.function.context.id',
- specversion: 'gcp.function.context.specversion',
- time: 'gcp.function.context.time',
- eventId: 'gcp.function.context.event_id',
- timestamp: 'gcp.function.context.timestamp',
- eventType: 'gcp.function.context.event_type',
- resource: 'gcp.function.context.resource',
+ type: GCP_FUNCTION_CONTEXT_TYPE,
+ source: GCP_FUNCTION_CONTEXT_SOURCE,
+ id: GCP_FUNCTION_CONTEXT_ID,
+ specversion: GCP_FUNCTION_CONTEXT_SPECVERSION,
+ time: GCP_FUNCTION_CONTEXT_TIME,
+ eventId: GCP_FUNCTION_CONTEXT_EVENT_ID,
+ timestamp: GCP_FUNCTION_CONTEXT_TIMESTAMP,
+ eventType: GCP_FUNCTION_CONTEXT_EVENT_TYPE,
+ resource: GCP_FUNCTION_CONTEXT_RESOURCE,
};
const _gcpContextIntegration = (() => {
diff --git a/packages/hono/package.json b/packages/hono/package.json
index 535f302a3358..be9722f8256f 100644
--- a/packages/hono/package.json
+++ b/packages/hono/package.json
@@ -74,7 +74,7 @@
"dependencies": {
"@opentelemetry/api": "^1.9.1",
"@sentry/core": "11.10.0",
- "@sentry/conventions": "^0.29.0",
+ "@sentry/conventions": "^0.30.0",
"@sentry/server-utils": "11.10.0"
},
"peerDependencies": {
@@ -112,7 +112,7 @@
"@types/node": "^20.19.0",
"hono": "4.13.11",
"hono-5": "npm:hono@5.0.0-rc.1",
- "wrangler": "4.62.0"
+ "wrangler": "4.156.0"
},
"scripts": {
"build": "run-p build:transpile build:types",
diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json
index 5df8376876a0..139bb6984ba0 100644
--- a/packages/nestjs/package.json
+++ b/packages/nestjs/package.json
@@ -54,14 +54,14 @@
"access": "public"
},
"dependencies": {
- "@sentry/conventions": "^0.29.0",
+ "@sentry/conventions": "^0.30.0",
"@sentry/core": "11.10.0",
"@sentry/node": "11.10.0",
"@sentry/server-utils": "11.10.0"
},
"devDependencies": {
- "@nestjs/common": "^10.0.0",
- "@nestjs/core": "^10.0.0",
+ "@nestjs/common": "^10.4.22",
+ "@nestjs/core": "^10.4.22",
"@sentry-internal/rollup-utils": "11.10.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
diff --git a/packages/nestjs/src/decorators.ts b/packages/nestjs/src/decorators.ts
index 4d5762481d2f..0a74734adb8b 100644
--- a/packages/nestjs/src/decorators.ts
+++ b/packages/nestjs/src/decorators.ts
@@ -1,34 +1,269 @@
import type { MonitorConfig } from '@sentry/core';
import { CODE_FUNCTION_NAME, SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes';
-import { captureException } from '@sentry/core';
+import { captureException, debug } from '@sentry/core';
import * as Sentry from '@sentry/node';
import { startSpan } from '@sentry/node';
+import { DEBUG_BUILD } from './debug-build';
import { isExpectedError } from './helpers';
-import { copyReflectMetadata } from './integrations/helpers';
+import type { ReflectWithMetadata } from './integrations/helpers';
+import { copyReflectMetadata, getNestScheduleVersion, markSentryCronMethod } from './integrations/helpers';
+
+/**
+ * Monitor settings for `@SentryCron` whose schedule and time zone come from the `@Cron()` decorator
+ * of `@nestjs/schedule` on the same method. Set `fromCronDecorator: false` to not send them.
+ */
+export type SentryCronMonitorSettings = Omit & {
+ schedule?: never;
+ timezone?: never;
+ fromCronDecorator?: boolean;
+};
/**
* A decorator wrapping the native nest Cron decorator, sending check-ins to Sentry.
+ *
+ * Unless a monitor config with a `schedule` is passed, the schedule and time zone of the method's
+ * `@Cron()` decorator are sent with each check-in, so Sentry can create the monitor on the first run.
*/
-export const SentryCron = (monitorSlug: string, monitorConfig?: MonitorConfig): MethodDecorator => {
+export const SentryCron = (
+ monitorSlug: string,
+ monitorConfig?: (MonitorConfig & { fromCronDecorator?: never }) | SentryCronMonitorSettings,
+): MethodDecorator => {
return (target: unknown, propertyKey, descriptor: PropertyDescriptor) => {
const originalMethod = descriptor.value as (...args: unknown[]) => Promise;
- descriptor.value = function (...args: unknown[]) {
- return Sentry.withMonitor(
- monitorSlug,
- () => {
- return originalMethod.apply(this, args);
- },
- monitorConfig,
- );
+ let resolvedMonitorConfig: MonitorConfig | undefined;
+ let isolateTraceWithoutConfig = false;
+ let resolved = false;
+
+ const wrappedMethod = function (this: unknown, ...args: unknown[]): unknown {
+ if (!resolved) {
+ resolved = true;
+ // A `@Cron()` applied after `@SentryCron()` puts its metadata on this wrapper, so read it at call time.
+ resolvedMonitorConfig = resolveMonitorConfig(monitorSlug, monitorConfig, [
+ wrappedMethod,
+ (target as Record | undefined)?.[propertyKey],
+ ]);
+ isolateTraceWithoutConfig = !resolvedMonitorConfig && !!monitorConfig?.isolateTrace;
+ }
+
+ const runWithMonitor = (): unknown =>
+ Sentry.withMonitor(
+ monitorSlug,
+ () => {
+ return originalMethod.apply(this, args);
+ },
+ resolvedMonitorConfig,
+ );
+
+ // `withMonitor` reads `isolateTrace` from the monitor config, which is not sent without a schedule.
+ return isolateTraceWithoutConfig ? Sentry.startNewTrace(runWithMonitor) : runWithMonitor();
};
+ descriptor.value = wrappedMethod;
+
copyFunctionNameAndMetadata({ originalMethod, descriptor });
+ markSentryCronMethod(wrappedMethod);
return descriptor;
};
};
+function resolveMonitorConfig(
+ monitorSlug: string,
+ monitorConfig: MonitorConfig | SentryCronMonitorSettings | undefined,
+ candidates: unknown[],
+): MonitorConfig | undefined {
+ if (monitorConfig?.schedule) {
+ return monitorConfig;
+ }
+
+ const { fromCronDecorator = true, ...monitorSettings } = monitorConfig || {};
+ const cronConfig = fromCronDecorator ? getMonitorConfigFromNestCron(candidates) : undefined;
+
+ if (!cronConfig) {
+ const { isolateTrace: _isolateTrace, ...unsentSettings } = monitorSettings;
+ if (DEBUG_BUILD && Object.keys(unsentSettings).length) {
+ const reason = fromCronDecorator
+ ? 'no schedule could be taken from @Cron()'
+ : 'fromCronDecorator is false and no schedule was passed';
+ debug.warn(
+ `[SentryCron] The monitor settings for "${monitorSlug}" are not sent, because ${reason}. A monitor config needs a schedule.`,
+ );
+ }
+ return undefined;
+ }
+
+ return { ...monitorSettings, ...cronConfig };
+}
+
+const SCHEDULE_CRON_OPTIONS = 'SCHEDULE_CRON_OPTIONS';
+
+// `cron` presets (case-insensitive). Sentry accepts only the first five, so the others are sent as crontabs.
+const CRON_PRESETS: Record = {
+ '@yearly': '@yearly',
+ '@monthly': '@monthly',
+ '@weekly': '@weekly',
+ '@daily': '@daily',
+ '@hourly': '@hourly',
+ '@minutely': '* * * * *',
+ '@weekdays': '0 0 * * 1-5',
+ '@weekends': '0 0 * * 0,6',
+};
+
+interface NestCronOptions {
+ cronTime?: unknown;
+ timeZone?: unknown;
+ utcOffset?: unknown;
+}
+
+function getMonitorConfigFromNestCron(candidates: unknown[]): MonitorConfig | undefined {
+ const R = Reflect as ReflectWithMetadata;
+ if (typeof R.getMetadata !== 'function') {
+ return undefined;
+ }
+
+ for (const candidate of candidates) {
+ if (typeof candidate !== 'function') {
+ continue;
+ }
+ const cronOptions = R.getMetadata(SCHEDULE_CRON_OPTIONS, candidate);
+ if (cronOptions && typeof cronOptions === 'object') {
+ return nestCronOptionsToMonitorConfig(cronOptions, getNestScheduleVersion(cronOptions));
+ }
+ }
+
+ return undefined;
+}
+
+/**
+ * Converts the options `@Cron()` stores as metadata into a Sentry monitor config.
+ * Returns `undefined` for schedules Sentry can't represent.
+ */
+export function nestCronOptionsToMonitorConfig(
+ cronOptions: NestCronOptions,
+ scheduleVersion: string | undefined,
+): MonitorConfig | undefined {
+ const { cronTime, timeZone, utcOffset } = cronOptions;
+
+ // A fixed UTC offset has no IANA time zone equivalent.
+ if (typeof cronTime !== 'string' || utcOffset != null) {
+ return undefined;
+ }
+
+ const fields = cronTime.trim().split(/\s+/);
+ let crontab: string | undefined;
+ if (fields.length === 1) {
+ crontab = CRON_PRESETS[(fields[0] as string).toLowerCase()];
+ } else if (fields.length === 5) {
+ crontab = isSupportedCrontab(fields, scheduleVersion) ? fields.join(' ') : undefined;
+ } else if (fields.length === 6 && /^\d+$/.test(fields[0] as string)) {
+ // Sentry schedules have minute granularity, so only a fixed second can be dropped.
+ crontab = isSupportedCrontab(fields.slice(1), scheduleVersion) ? fields.slice(1).join(' ') : undefined;
+ }
+
+ if (!crontab) {
+ return undefined;
+ }
+
+ // Without a `timeZone`, `cron` uses the server's time zone, but Sentry would assume UTC.
+ const timezone = toSentryTimeZone(typeof timeZone === 'string' && timeZone ? timeZone : getLocalTimeZone());
+ if (!timezone) {
+ return undefined;
+ }
+
+ return {
+ schedule: { type: 'crontab', value: crontab },
+ timezone,
+ };
+}
+
+/**
+ * Whether Sentry reads the 5 crontab fields the same way `cron` (used by `@nestjs/schedule`) runs them.
+ */
+function isSupportedCrontab(
+ [, , dayOfMonth = '', month = '', dayOfWeek = '']: string[],
+ scheduleVersion: string | undefined,
+): boolean {
+ // `cron` 2.x (`@nestjs/schedule` 2 and 3) counts months from 0. Steps don't depend on that.
+ const countsMonthsFromOne = parseInt(scheduleVersion ?? '', 10) >= 4;
+ if (!countsMonthsFromOne && /\d/.test(month.replace(/\/\d+/g, ''))) {
+ return false;
+ }
+
+ const daysOfMonth = countCronValues(dayOfMonth, 1, 31);
+ const daysOfWeek = countCronValues(dayOfWeek, 0, 7);
+ if (daysOfMonth === undefined || daysOfWeek === undefined) {
+ return false;
+ }
+
+ // `cron` ignores a day field that covers every day (like `1-31` or `sun-sat`) and runs on either day field
+ // otherwise. Sentry reads full ranges as they are and needs both day fields when one starts with `*` (like `*/2`).
+ const allDaysOfMonth = daysOfMonth === 31;
+ const allDaysOfWeek = daysOfWeek === 7;
+ const cronRunsOnEither = !allDaysOfMonth && !allDaysOfWeek;
+ const sentryRunsOnEither = !dayOfMonth.startsWith('*') && !dayOfWeek.startsWith('*');
+ return (allDaysOfMonth && allDaysOfWeek) || cronRunsOnEither === sentryRunsOnEither;
+}
+
+const DAY_NAMES: Record = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
+
+/**
+ * How many days a day field matches, expanded the way `cron` does it, or `undefined` if `cron` can't parse it.
+ * Day of week `7` is Sunday, like `0`.
+ */
+function countCronValues(field: string, low: number, high: number): number | undefined {
+ let unknownName = false;
+ const numericField = field.replace(/[a-z]{1,3}/gi, name => {
+ const value = DAY_NAMES[name.toLowerCase()];
+ unknownName ||= value === undefined;
+ return String(value);
+ });
+ if (unknownName) {
+ return undefined;
+ }
+
+ const values = new Set();
+ for (const part of numericField.split(',')) {
+ const match = part.match(/^(?:(\*)|(\d+)(?:-(\d+))?)(?:\/(\d+))?$/);
+ if (!match) {
+ return undefined;
+ }
+ const [, wildcard, from, to, step] = match;
+ const lower = wildcard ? low : Number(from);
+ const upper = wildcard || (step && !to) ? high : Number(to ?? from);
+ const increment = Number(step ?? 1);
+ if (!increment || lower < low || upper > high || lower > upper) {
+ return undefined;
+ }
+ for (let value = lower; value <= upper; value += increment) {
+ values.add(high === 7 && value === 7 ? 0 : value);
+ }
+ }
+ return values.size;
+}
+
+/**
+ * The canonical IANA name of the time zone, or `undefined` if Sentry won't accept it (like `UTC+3`).
+ */
+function toSentryTimeZone(timezone: string | undefined): string | undefined {
+ if (!timezone || timezone === 'Etc/Unknown' || /^(?:utc|gmt)?[+-]/i.test(timezone)) {
+ return undefined;
+ }
+ try {
+ return new Intl.DateTimeFormat('en-US', { timeZone: timezone }).resolvedOptions().timeZone || undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+function getLocalTimeZone(): string | undefined {
+ try {
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
+ } catch {
+ return undefined;
+ }
+}
+
/**
* A decorator usable to wrap arbitrary functions with spans.
*/
diff --git a/packages/nestjs/src/index.ts b/packages/nestjs/src/index.ts
index b96fb4c5390a..683239d3c908 100644
--- a/packages/nestjs/src/index.ts
+++ b/packages/nestjs/src/index.ts
@@ -4,7 +4,9 @@
export * from '@sentry/node';
export { nestIntegration } from './integrations/nest';
+export type { NestIntegrationOptions } from './integrations/nest';
export { getDefaultIntegrations, init } from './sdk';
export { SentryCron, SentryExceptionCaptured, SentryTraced } from './decorators';
+export type { SentryCronMonitorSettings } from './decorators';
diff --git a/packages/nestjs/src/integrations/cron-monitoring.ts b/packages/nestjs/src/integrations/cron-monitoring.ts
new file mode 100644
index 000000000000..30c6e74b5f48
--- /dev/null
+++ b/packages/nestjs/src/integrations/cron-monitoring.ts
@@ -0,0 +1,131 @@
+import type { Client, MonitorConfig } from '@sentry/core';
+import { debug, getClient, stringMatchesSomePattern } from '@sentry/core';
+import { DEBUG_BUILD } from '../debug-build';
+import { nestCronOptionsToMonitorConfig } from '../decorators';
+import { getCronOptions, isSentryCronMethod, markSentryCronMethod } from './helpers';
+
+export interface NestCronMonitoringOptions {
+ /**
+ * Send check-ins for every `@Cron()` method of `@nestjs/schedule`. The monitor slug is the `name`
+ * option of `@Cron()`, or `ClassName.methodName`. Methods with `@SentryCron()` keep their own slug.
+ * Methods whose schedule Sentry can't represent, like every 10 seconds, are not monitored.
+ * Sentry cuts slugs off at 50 characters, so give `@Cron()` a `name` if the class and method names are long.
+ *
+ * Defaults to `false`.
+ */
+ monitorCronJobs?: boolean;
+
+ /**
+ * Monitor slugs not to monitor when `monitorCronJobs` is enabled. Strings must match the slug exactly.
+ */
+ excludeCronJobs?: Array;
+}
+
+interface CronMonitor {
+ slug: string;
+ config: MonitorConfig;
+}
+
+const MONITORING_OPTIONS = new WeakMap();
+
+/** Enables `@Cron()` monitoring for the client. */
+export function setCronMonitoringOptions(client: Client, options: NestCronMonitoringOptions): void {
+ if (options.monitorCronJobs) {
+ MONITORING_OPTIONS.set(client, options);
+ }
+}
+
+const CRON_MONITORS = new WeakMap CronMonitor | undefined>();
+
+/**
+ * Sets up `monitorCronJobs` for a method `@Cron()` just decorated, keyed by its `@Cron()` options, which
+ * decorators above it copy. A `@SentryCron()` below marks the options instead, so the method keeps its own slug.
+ */
+export function registerCronMonitor(
+ method: object,
+ handler: object,
+ cronArgs: unknown[],
+ target: unknown,
+ propertyKey: string | symbol | undefined,
+ scheduleVersion?: string,
+): void {
+ if (isSentryCronMethod(handler)) {
+ markSentryCronMethod(method);
+ return;
+ }
+ const options = getCronOptions(method);
+ if (options) {
+ CRON_MONITORS.set(options, makeCronMonitorResolver(cronArgs, target, propertyKey, scheduleVersion));
+ }
+}
+
+/** The monitor a run of this `@Cron()` method sends check-ins for, if any. */
+export function resolveCronMonitor(method: object): CronMonitor | undefined {
+ const options = getCronOptions(method);
+ return options && CRON_MONITORS.get(options)?.(method);
+}
+
+function makeCronMonitorResolver(
+ cronArgs: unknown[],
+ target: unknown,
+ propertyKey: string | symbol | undefined,
+ scheduleVersion: string | undefined,
+): (method: object) => CronMonitor | undefined {
+ let monitor: CronMonitor | null | undefined;
+
+ return method => {
+ const client = getClient();
+ const options = client && MONITORING_OPTIONS.get(client);
+ if (!options) {
+ return undefined;
+ }
+
+ if (monitor === undefined) {
+ try {
+ monitor = isSentryCronMethod(method) ? null : getCronMonitor(cronArgs, target, propertyKey, scheduleVersion);
+ } catch (e) {
+ DEBUG_BUILD && debug.error('[Nest] Failed to set up the cron monitor:', e);
+ monitor = null;
+ }
+ }
+
+ if (!monitor || stringMatchesSomePattern(monitor.slug, options.excludeCronJobs, true)) {
+ return undefined;
+ }
+ return monitor;
+ };
+}
+
+function getCronMonitor(
+ [cronTime, cronOptions]: unknown[],
+ target: unknown,
+ propertyKey: string | symbol | undefined,
+ scheduleVersion: string | undefined,
+): CronMonitor | null {
+ // A `Date` runs once, so there is nothing to monitor.
+ if (typeof cronTime !== 'string') {
+ return null;
+ }
+
+ const options = (cronOptions && typeof cronOptions === 'object' ? cronOptions : {}) as Record;
+ const className = (target as { constructor?: { name?: unknown } } | undefined)?.constructor?.name;
+ const slug =
+ typeof options.name === 'string' && options.name
+ ? options.name
+ : typeof className === 'string' && className && propertyKey !== undefined
+ ? `${className}.${String(propertyKey)}`
+ : undefined;
+
+ if (!slug) {
+ DEBUG_BUILD && debug.warn('[Nest] Not monitoring a @Cron() method without a name.');
+ return null;
+ }
+
+ const config = nestCronOptionsToMonitorConfig({ ...options, cronTime }, scheduleVersion);
+ if (!config) {
+ DEBUG_BUILD && debug.log(`[Nest] Not monitoring ${slug}: Sentry can't represent its schedule.`);
+ return null;
+ }
+
+ return { slug, config };
+}
diff --git a/packages/nestjs/src/integrations/helpers.ts b/packages/nestjs/src/integrations/helpers.ts
index 5056413e911a..dd759e13825a 100644
--- a/packages/nestjs/src/integrations/helpers.ts
+++ b/packages/nestjs/src/integrations/helpers.ts
@@ -9,7 +9,13 @@ import {
} from '@sentry/conventions/attributes';
import { FUNCTION, MIDDLEWARE, QUEUE_PROCESS } from '@sentry/conventions/op';
import type { Span } from '@sentry/core';
-import { addNonEnumerableProperty, getClient, hasSpanStreamingEnabled, withActiveSpan } from '@sentry/core';
+import {
+ addNonEnumerableProperty,
+ getClient,
+ hasSpanStreamingEnabled,
+ isObjectLike,
+ withActiveSpan,
+} from '@sentry/core';
import type { CatchTarget, InjectableTarget, NextFunction, Observable, Subscription } from './types';
/** A function of unknown signature, matching the methods/handlers we wrap. */
@@ -31,6 +37,64 @@ export function markWrapped(fn: AnyFn): void {
(fn as AnyFn & Record)[SENTRY_WRAPPED] = true;
}
+// `@nestjs/schedule`'s metadata key for the `@Cron()` options.
+const SCHEDULE_CRON_OPTIONS = 'SCHEDULE_CRON_OPTIONS';
+const SENTRY_CRON_METADATA = 'sentry:nestjs:sentry_cron';
+
+/** The options `@Cron()` stored as metadata on `fn`, if any. */
+export function getCronOptions(fn: object): object | undefined {
+ const R = Reflect as ReflectWithMetadata;
+ const options = typeof R.getMetadata === 'function' ? R.getMetadata(SCHEDULE_CRON_OPTIONS, fn) : undefined;
+ return isObjectLike(options) ? options : undefined;
+}
+
+/**
+ * Marks a method that `@SentryCron()` monitors, and the options of its `@Cron()` if it already has one, so
+ * `@Cron()` monitoring skips it. Decorators between it and a `@Cron()` above copy the metadata outward.
+ */
+export function markSentryCronMethod(fn: object): void {
+ const R = Reflect as ReflectWithMetadata;
+ if (typeof R.defineMetadata !== 'function') {
+ return;
+ }
+ R.defineMetadata(SENTRY_CRON_METADATA, true, fn);
+ const cronOptions = getCronOptions(fn);
+ if (cronOptions) {
+ R.defineMetadata(SENTRY_CRON_METADATA, true, cronOptions);
+ }
+}
+
+/** Whether `fn` or its `@Cron()` options are marked by {@link markSentryCronMethod}. */
+export function isSentryCronMethod(fn: object): boolean {
+ const R = Reflect as ReflectWithMetadata;
+ if (typeof R.getMetadata !== 'function') {
+ return false;
+ }
+ const cronOptions = getCronOptions(fn);
+ return (
+ R.getMetadata(SENTRY_CRON_METADATA, fn) === true ||
+ (!!cronOptions && R.getMetadata(SENTRY_CRON_METADATA, cronOptions) === true)
+ );
+}
+
+const NEST_SCHEDULE_VERSIONS = new WeakMap();
+
+/** Remembers the `@nestjs/schedule` version whose `@Cron()` stored the options on `method`. */
+export function setNestScheduleVersion(method: unknown, version: string | undefined): void {
+ if (typeof method !== 'function' || !version) {
+ return;
+ }
+ const cronOptions = getCronOptions(method);
+ if (cronOptions) {
+ NEST_SCHEDULE_VERSIONS.set(cronOptions, version);
+ }
+}
+
+/** The `@nestjs/schedule` version that stored these `@Cron()` options, if the SDK saw the decorator run. */
+export function getNestScheduleVersion(cronOptions: object): string | undefined {
+ return NEST_SCHEDULE_VERSIONS.get(cronOptions);
+}
+
/**
* The subset of `reflect-metadata`'s `Reflect` augmentation that NestJS
* relies on. Methods are optional because `reflect-metadata` may not be
diff --git a/packages/nestjs/src/integrations/nest.ts b/packages/nestjs/src/integrations/nest.ts
index 01ef7d722e9c..853caf825d71 100644
--- a/packages/nestjs/src/integrations/nest.ts
+++ b/packages/nestjs/src/integrations/nest.ts
@@ -1,16 +1,23 @@
import { defineIntegration } from '@sentry/core';
+import type { NestCronMonitoringOptions } from './cron-monitoring';
+import { setCronMonitoringOptions } from './cron-monitoring';
import { subscribeToNestChannels } from './orchestrion-subscriber';
const INTEGRATION_NAME = 'Nest' as const;
+export type NestIntegrationOptions = NestCronMonitoringOptions;
+
/**
* Integration capturing tracing data for NestJS.
*/
-export const nestIntegration = defineIntegration(() => {
+export const nestIntegration = defineIntegration((options: NestIntegrationOptions = {}) => {
return {
name: INTEGRATION_NAME,
setupOnce() {
subscribeToNestChannels();
},
+ setup(client) {
+ setCronMonitoringOptions(client, options);
+ },
};
});
diff --git a/packages/nestjs/src/integrations/orchestrion-subscriber.ts b/packages/nestjs/src/integrations/orchestrion-subscriber.ts
index 53e21dda735f..53dde4285a7f 100644
--- a/packages/nestjs/src/integrations/orchestrion-subscriber.ts
+++ b/packages/nestjs/src/integrations/orchestrion-subscriber.ts
@@ -1,20 +1,22 @@
import * as diagnosticsChannel from 'node:diagnostics_channel';
-import { debug, startInactiveSpan, waitForTracingChannelBinding } from '@sentry/core';
+import { addNonEnumerableProperty, debug, startInactiveSpan, waitForTracingChannelBinding } from '@sentry/core';
import type { OrchestrionChannelContext } from '@sentry/server-utils';
import { bindTracingChannelToSpan } from '@sentry/server-utils';
import { nestjsChannels as CHANNELS } from '@sentry/server-utils/orchestrion/config';
import { DEBUG_BUILD } from '../debug-build';
+import { registerCronMonitor } from './cron-monitoring';
import type { AnyFn } from './helpers';
-import { isWrapped, markWrapped } from './helpers';
+import { isWrapped, markWrapped, setNestScheduleVersion } from './helpers';
import type { CatchTarget, InjectableTarget, MinimalNestJsExecutionContext } from './types';
import { patchCatchTarget, patchInjectableTarget } from './wrap-components';
import {
extractQueueName,
- MECHANISM_CRON,
MECHANISM_INTERVAL,
MECHANISM_TIMEOUT,
patchMethodDescriptor,
patchProcessorTarget,
+ sendsCronCheckIns,
+ wrapCronHandler,
wrapEventHandler,
wrapScheduleHandler,
} from './wrap-handlers';
@@ -184,9 +186,44 @@ export function subscribeToNestChannels(): void {
subscribeDecoratorChannel(CHANNELS.NESTJS_CATCH, patchCatchTarget);
// @Cron/@Interval/@Timeout (schedule), @OnEvent (event), @Processor (bullmq)
- subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_CRON, decorator =>
- makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_CRON)),
- );
+ subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_CRON, (decorator, data) => {
+ const cronDecorator = makeMethodDecorator(decorator, handler => wrapCronHandler(handler));
+ return function (this: unknown, ...args: unknown[]): unknown {
+ const descriptor = args[2] as PropertyDescriptor | undefined;
+ const handler: unknown = descriptor?.value;
+ const result = cronDecorator.apply(this, args);
+ const method: unknown = descriptor?.value;
+ setNestScheduleVersion(method, data.moduleVersion);
+ if (typeof method === 'function' && typeof handler === 'function' && method !== handler) {
+ registerCronMonitor(
+ method,
+ handler,
+ [...(data.arguments || [])],
+ args[0],
+ args[1] as string | symbol | undefined,
+ data.moduleVersion,
+ );
+ }
+ return result;
+ };
+ });
+ // `@nestjs/schedule` passes this function to `CronJob` as `onTick`. When the method already sends
+ // check-ins, mark it the way other monitored jobs are marked, so scheduler integrations skip it.
+ diagnosticsChannel.tracingChannel(CHANNELS.NESTJS_SCHEDULE_EXPLORER_WRAP).subscribe({
+ start: NOOP,
+ end(data) {
+ try {
+ if (typeof data.result === 'function' && sendsCronCheckIns(data.arguments?.[0])) {
+ addNonEnumerableProperty(data.result, Symbol.for('sentry.cron.monitored'), true);
+ }
+ } catch (e) {
+ DEBUG_BUILD && debug.error('[Nest] Failed to check whether a @Cron() job sends check-ins:', e);
+ }
+ },
+ asyncStart: NOOP,
+ asyncEnd: NOOP,
+ error: NOOP,
+ });
subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_INTERVAL, decorator =>
makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_INTERVAL)),
);
diff --git a/packages/nestjs/src/integrations/wrap-handlers.ts b/packages/nestjs/src/integrations/wrap-handlers.ts
index 62f677635eeb..a9fcb58289d5 100644
--- a/packages/nestjs/src/integrations/wrap-handlers.ts
+++ b/packages/nestjs/src/integrations/wrap-handlers.ts
@@ -1,6 +1,21 @@
-import { captureException, isObjectLike, isThenable, startSpan, withIsolationScope } from '@sentry/core';
+import {
+ captureException,
+ isObjectLike,
+ isThenable,
+ startNewTrace,
+ startSpan,
+ withIsolationScope,
+ withMonitor,
+} from '@sentry/core';
+import { resolveCronMonitor } from './cron-monitoring';
import type { AnyFn, ReflectWithMetadata } from './helpers';
-import { getBullMQProcessSpanOptions, getEventSpanOptions, isWrapped, markWrapped } from './helpers';
+import {
+ getBullMQProcessSpanOptions,
+ getEventSpanOptions,
+ isSentryCronMethod,
+ isWrapped,
+ markWrapped,
+} from './helpers';
/**
* Span-emitting / error-capturing logic for the `@Cron`/`@Interval`/
@@ -56,6 +71,26 @@ export function wrapScheduleHandler(handler: AnyFn, mechanismType: string): AnyF
};
}
+/** Whether a `@Cron()` method sends check-ins when it runs, from `monitorCronJobs` or `@SentryCron()`. */
+export function sendsCronCheckIns(method: unknown): boolean {
+ return typeof method === 'function' && (isSentryCronMethod(method) || !!resolveCronMonitor(method));
+}
+
+/**
+ * Wrap a `@Cron` handler like {@link wrapScheduleHandler}, sending check-ins when `monitorCronJobs` is enabled.
+ */
+export function wrapCronHandler(handler: AnyFn): AnyFn {
+ const scheduleHandler = wrapScheduleHandler(handler, MECHANISM_CRON);
+ const wrapped = function (this: unknown, ...args: unknown[]): unknown {
+ const monitor = resolveCronMonitor(wrapped);
+ if (!monitor) {
+ return scheduleHandler.apply(this, args);
+ }
+ return startNewTrace(() => withMonitor(monitor.slug, () => scheduleHandler.apply(this, args), monitor.config));
+ };
+ return wrapped;
+}
+
function eventNameFromEvent(event: unknown): string {
if (typeof event === 'string') {
return event;
@@ -140,11 +175,11 @@ export function patchMethodDescriptor(
target: { __SENTRY_INTERNAL__?: boolean } | undefined,
propertyKey: string | symbol | undefined,
descriptor: PropertyDescriptor | undefined,
- wrapHandler: (handler: AnyFn) => AnyFn,
+ wrapHandler: (handler: AnyFn, target: unknown, propertyKey: string | symbol | undefined) => AnyFn,
): void {
const handler = descriptor?.value as AnyFn | undefined;
if (descriptor && handler && typeof handler === 'function' && !target?.__SENTRY_INTERNAL__ && !isWrapped(handler)) {
- const wrapped = wrapHandler(handler);
+ const wrapped = wrapHandler(handler, target, propertyKey);
Object.defineProperty(wrapped, 'name', {
value: handler.name || String(propertyKey),
configurable: true,
diff --git a/packages/nestjs/test/decorators.test.ts b/packages/nestjs/test/decorators.test.ts
index 661f00b89182..47bc07d14ab4 100644
--- a/packages/nestjs/test/decorators.test.ts
+++ b/packages/nestjs/test/decorators.test.ts
@@ -1,9 +1,14 @@
import 'reflect-metadata';
+import { tracingChannel } from 'node:diagnostics_channel';
+import { SetMetadata } from '@nestjs/common';
import { CODE_FUNCTION_NAME, SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes';
import * as core from '@sentry/core';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { OrchestrionChannelContext } from '@sentry/server-utils';
+import { nestjsChannels as CHANNELS } from '@sentry/server-utils/orchestrion/config';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SentryCron, SentryExceptionCaptured, SentryTraced } from '../src/decorators';
import * as helpers from '../src/helpers';
+import { subscribeToNestChannels } from '../src/integrations/orchestrion-subscriber';
describe('SentryTraced decorator', () => {
beforeEach(() => {
@@ -258,6 +263,291 @@ describe('SentryCron decorator', () => {
});
});
+describe('SentryCron decorator with @Cron', () => {
+ // Mirrors `@Cron()` of `@nestjs/schedule`, which stores its options under this metadata key.
+ const Cron = (cronTime: unknown, options: Record = {}): MethodDecorator =>
+ SetMetadata('SCHEDULE_CRON_OPTIONS', { ...options, cronTime });
+
+ // `@Cron()` as the SDK's instrumentation sees it, with the `@nestjs/schedule` version.
+ const VersionedCron = (cronTime: string, moduleVersion: string): MethodDecorator => {
+ subscribeToNestChannels();
+ const data: OrchestrionChannelContext = { arguments: [cronTime], moduleVersion };
+ tracingChannel(CHANNELS.NESTJS_SCHEDULE_CRON).traceSync(() => Cron(cronTime), data);
+ return data.result as MethodDecorator;
+ };
+
+ // Mirrors the SDK's own `@Cron()` instrumentation, which swaps the method before nest stores the metadata.
+ const WrappingCron =
+ (cronTime: unknown): MethodDecorator =>
+ (target, propertyKey, descriptor) => {
+ const original = descriptor.value as unknown as (...args: unknown[]) => unknown;
+ (descriptor as PropertyDescriptor).value = function (this: unknown, ...args: unknown[]) {
+ return original.apply(this, args);
+ };
+ return Cron(cronTime)(target, propertyKey, descriptor);
+ };
+
+ // Applies decorators the way TypeScript does: listed top to bottom, applied bottom to top.
+ function decorate(...decorators: MethodDecorator[]): { job: () => Promise } {
+ class Service {}
+ let descriptor: PropertyDescriptor = {
+ value: async () => 'done',
+ writable: true,
+ enumerable: false,
+ configurable: true,
+ };
+ for (const decorator of [...decorators].reverse()) {
+ descriptor = (decorator(Service.prototype, 'job', descriptor) as PropertyDescriptor | undefined) ?? descriptor;
+ }
+ Object.defineProperty(Service.prototype, 'job', descriptor);
+ return new Service() as { job: () => Promise };
+ }
+
+ beforeEach(() => {
+ // `@Cron()` without a `timeZone` runs in the server's local time zone.
+ const DateTimeFormat = Intl.DateTimeFormat;
+ vi.spyOn(Intl, 'DateTimeFormat').mockImplementation(function (
+ locales?: string | string[],
+ options?: Intl.DateTimeFormatOptions,
+ ) {
+ return new DateTimeFormat(locales, { timeZone: 'Asia/Tokyo', ...options });
+ } as typeof Intl.DateTimeFormat);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('derives the monitor config when @SentryCron is above @Cron', async () => {
+ const withMonitorSpy = vi.spyOn(core, 'withMonitor');
+ const service = decorate(SentryCron('my-job'), Cron('0 * * * *'));
+
+ expect(await service.job()).toBe('done');
+ expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), {
+ schedule: { type: 'crontab', value: '0 * * * *' },
+ timezone: 'Asia/Tokyo',
+ });
+ });
+
+ it('derives the monitor config when @Cron is above @SentryCron', async () => {
+ const withMonitorSpy = vi.spyOn(core, 'withMonitor');
+ const service = decorate(Cron('0 * * * *'), SentryCron('my-job'));
+
+ await service.job();
+ expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), {
+ schedule: { type: 'crontab', value: '0 * * * *' },
+ timezone: 'Asia/Tokyo',
+ });
+ });
+
+ it('derives the monitor config when @Cron replaces the method', async () => {
+ const withMonitorSpy = vi.spyOn(core, 'withMonitor');
+ const service = decorate(WrappingCron('0 * * * *'), SentryCron('my-job'));
+
+ await service.job();
+ expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), {
+ schedule: { type: 'crontab', value: '0 * * * *' },
+ timezone: 'Asia/Tokyo',
+ });
+ });
+
+ it('drops a fixed seconds field and passes the time zone', async () => {
+ const withMonitorSpy = vi.spyOn(core, 'withMonitor');
+ const service = decorate(SentryCron('my-job'), Cron('0 30 9 * * 1-5', { timeZone: 'Europe/Vienna' }));
+
+ await service.job();
+ expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), {
+ schedule: { type: 'crontab', value: '30 9 * * 1-5' },
+ timezone: 'Europe/Vienna',
+ });
+ });
+
+ it('sends the canonical time zone name', async () => {
+ const withMonitorSpy = vi.spyOn(core, 'withMonitor');
+ const service = decorate(SentryCron('my-job'), Cron('0 * * * *', { timeZone: 'europe/vienna' }));
+
+ await service.job();
+ expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), {
+ schedule: { type: 'crontab', value: '0 * * * *' },
+ timezone: 'Europe/Vienna',
+ });
+ });
+
+ it.each([
+ ['a sub-minute schedule', Cron('*/5 * * * * *')],
+ ['a one-off date', Cron(new Date())],
+ ['an unknown preset', Cron('@reboot')],
+ ['a utc offset', Cron('0 * * * *', { utcOffset: 120 })],
+ ['a numeric month', Cron('0 9 1 5 *')],
+ ['a numeric month range', Cron('0 9 1 1-6 *')],
+ ['a numeric month list', Cron('0 9 1 1,7 *')],
+ ['a numeric month with a step', Cron('0 9 1 2/3 *')],
+ // `0 0 1 0 *` in `@nestjs/schedule` 3
+ ['CronExpression.EVERY_YEAR', Cron('0 0 1 1 *')],
+ ['a day-of-month step with a day of week', Cron('0 9 */2 * MON')],
+ ['a full day-of-month range with a day of week', Cron('0 9 1-31 * MON')],
+ ['a full day-of-week range with a day of month', Cron('0 9 1 * 0-6')],
+ ['a full 1-7 day-of-week range with a day of month', Cron('0 9 15 * 1-7')],
+ ['a full day-of-week name range with a day of month', Cron('0 9 1 * sun-sat')],
+ ['a full upper-case day-of-week name range with a day of month', Cron('0 9 1 * SUN-SAT')],
+ ['a full mon-sun day-of-week range with a day of month', Cron('0 9 1 * mon-sun')],
+ ['a full day-of-week list with a day of month', Cron('0 9 1 * 0,1,2,3,4,5,6')],
+ ['a full day-of-month range with a step of 1 and a day of week', Cron('0 9 1-31/1 * MON')],
+ ['an unknown day name', Cron('0 9 1 * sunday')],
+ ['an out-of-range day of month', Cron('0 9 32 * *')],
+ ['a numeric month from @nestjs/schedule 3', VersionedCron('0 9 1 5 *', '3.0.4')],
+ ['a fixed-offset time zone', Cron('0 * * * *', { timeZone: 'UTC+3' })],
+ ['an unknown time zone', Cron('0 * * * *', { timeZone: 'Mars/Olympus' })],
+ ])('sends no monitor config for %s', async (_, cronDecorator) => {
+ const withMonitorSpy = vi.spyOn(core, 'withMonitor');
+ const service = decorate(SentryCron('my-job'), cronDecorator);
+
+ await service.job();
+ expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), undefined);
+ });
+
+ it.each([
+ ['a month name', '0 9 1 MAY *'],
+ ['both day fields', '0 9 1-7 * MON'],
+ ['a day-of-week name list with a day of month', '0 9 1 * mon,fri'],
+ ['a full day-of-month range with a day-of-week step', '0 9 1-31 * */2'],
+ ['a day-of-month step of 1 with a day of week', '0 9 */1 * MON'],
+ ['both day fields covering every day', '0 9 1-31 * sun-sat'],
+ ['CronExpression.EVERY_QUARTER', '0 0 1 */3 *'],
+ ['CronExpression.EVERY_6_MONTHS', '0 0 1 */6 *'],
+ ])('sends a crontab with %s', async (_, crontab) => {
+ const withMonitorSpy = vi.spyOn(core, 'withMonitor');
+ const service = decorate(SentryCron('my-job'), Cron(crontab));
+
+ await service.job();
+ expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), {
+ schedule: { type: 'crontab', value: crontab },
+ timezone: 'Asia/Tokyo',
+ });
+ });
+
+ it.each([
+ ['a numeric month', '0 9 1 5 *'],
+ ['a numeric month range', '0 9 1 1-6 *'],
+ ['CronExpression.EVERY_YEAR', '0 0 1 1 *'],
+ ])('sends a crontab with %s from @nestjs/schedule 4 or later', async (_, crontab) => {
+ const withMonitorSpy = vi.spyOn(core, 'withMonitor');
+ const service = decorate(SentryCron('my-job'), VersionedCron(crontab, '4.1.2'));
+
+ await service.job();
+ expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), {
+ schedule: { type: 'crontab', value: crontab },
+ timezone: 'Asia/Tokyo',
+ });
+ });
+
+ it('sends no monitor config when the local time zone is unknown', async () => {
+ vi.mocked(Intl.DateTimeFormat).mockImplementation(function () {
+ return { resolvedOptions: () => ({ timeZone: 'Etc/Unknown' }) };
+ } as unknown as typeof Intl.DateTimeFormat);
+ const withMonitorSpy = vi.spyOn(core, 'withMonitor');
+ const service = decorate(SentryCron('my-job'), Cron('0 * * * *'));
+
+ await service.job();
+ expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), undefined);
+ });
+
+ it.each([
+ ['@hourly', '@hourly'],
+ ['@daily', '@daily'],
+ ['@WEEKLY', '@weekly'],
+ ['@monthly', '@monthly'],
+ ['@yearly', '@yearly'],
+ ['@weekdays', '0 0 * * 1-5'],
+ ])('sends the preset %s as %s', async (preset, value) => {
+ const withMonitorSpy = vi.spyOn(core, 'withMonitor');
+ const service = decorate(SentryCron('my-job'), Cron(preset));
+
+ await service.job();
+ expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), {
+ schedule: { type: 'crontab', value },
+ timezone: 'Asia/Tokyo',
+ });
+ });
+
+ it('warns when monitor settings are passed but no schedule can be derived', async () => {
+ const warnSpy = vi.spyOn(core.debug, 'warn').mockImplementation(() => undefined);
+ const withMonitorSpy = vi.spyOn(core, 'withMonitor').mockImplementation((_, callback) => callback());
+ const service = decorate(SentryCron('my-job', { checkinMargin: 2 }), Cron('*/5 * * * * *'));
+
+ await service.job();
+ await service.job();
+ expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), undefined);
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"my-job"'));
+ });
+
+ it('does not warn when no monitor settings are passed', async () => {
+ const warnSpy = vi.spyOn(core.debug, 'warn').mockImplementation(() => undefined);
+ vi.spyOn(core, 'withMonitor').mockImplementation((_, callback) => callback());
+ const service = decorate(SentryCron('my-job'), Cron('*/5 * * * * *'));
+
+ await service.job();
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+
+ it('sends no monitor config with fromCronDecorator: false', async () => {
+ const warnSpy = vi.spyOn(core.debug, 'warn').mockImplementation(() => undefined);
+ const withMonitorSpy = vi.spyOn(core, 'withMonitor');
+ const service = decorate(SentryCron('my-job', { fromCronDecorator: false, checkinMargin: 2 }), Cron('0 * * * *'));
+
+ await service.job();
+ expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), undefined);
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('fromCronDecorator is false'));
+ });
+
+ it('isolates the trace without a monitor config', async () => {
+ const warnSpy = vi.spyOn(core.debug, 'warn').mockImplementation(() => undefined);
+ const traceIds: string[] = [];
+ const withMonitorSpy = vi.spyOn(core, 'withMonitor').mockImplementation((_, callback) => {
+ traceIds.push(core.getCurrentScope().getPropagationContext().traceId);
+ return callback();
+ });
+ const service = decorate(SentryCron('my-job', { isolateTrace: true }), Cron('*/5 * * * * *'));
+ const parentTraceId = core.getCurrentScope().getPropagationContext().traceId;
+
+ await service.job();
+ expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), undefined);
+ expect(traceIds).toHaveLength(1);
+ expect(traceIds[0]).not.toBe(parentTraceId);
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+
+ it('keeps the other monitor settings', async () => {
+ const withMonitorSpy = vi.spyOn(core, 'withMonitor');
+ const service = decorate(SentryCron('my-job', { checkinMargin: 2, maxRuntime: 10 }), Cron('0 * * * *'));
+
+ await service.job();
+ expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), {
+ schedule: { type: 'crontab', value: '0 * * * *' },
+ timezone: 'Asia/Tokyo',
+ checkinMargin: 2,
+ maxRuntime: 10,
+ });
+ });
+
+ it('uses an explicit monitor config as is', async () => {
+ const withMonitorSpy = vi.spyOn(core, 'withMonitor');
+ const monitorConfig: core.MonitorConfig = { schedule: { type: 'interval', value: 1, unit: 'hour' } };
+ const service = decorate(SentryCron('my-job', monitorConfig), Cron('0 * * * *'));
+
+ await service.job();
+ expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), monitorConfig);
+ });
+
+ it('does not accept a schedule or time zone together with fromCronDecorator', () => {
+ // @ts-expect-error - `fromCronDecorator` only applies to settings without a schedule
+ SentryCron('my-job', { schedule: { type: 'crontab', value: '0 * * * *' }, fromCronDecorator: false });
+ // @ts-expect-error - the time zone comes from `@Cron()`
+ SentryCron('my-job', { timezone: 'Europe/Vienna', checkinMargin: 2 });
+ });
+});
+
describe('SentryExceptionCaptured decorator', () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/packages/nestjs/test/integrations/cron-monitoring.test.ts b/packages/nestjs/test/integrations/cron-monitoring.test.ts
new file mode 100644
index 000000000000..56e2559e0a3a
--- /dev/null
+++ b/packages/nestjs/test/integrations/cron-monitoring.test.ts
@@ -0,0 +1,278 @@
+import 'reflect-metadata';
+import { tracingChannel } from 'node:diagnostics_channel';
+import * as SentryCore from '@sentry/core';
+import { Client, createTransport, initAndBind, resolvedSyncPromise } from '@sentry/core';
+import type { OrchestrionChannelContext } from '@sentry/server-utils';
+import { nestjsChannels as CHANNELS } from '@sentry/server-utils/orchestrion/config';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { SentryCron } from '../../src/decorators';
+import type { NestCronMonitoringOptions } from '../../src/integrations/cron-monitoring';
+import { setCronMonitoringOptions } from '../../src/integrations/cron-monitoring';
+import { subscribeToNestChannels } from '../../src/integrations/orchestrion-subscriber';
+
+type AnyFn = (this: unknown, ...args: unknown[]) => unknown;
+
+class TestClient extends Client {
+ public eventFromException(): PromiseLike {
+ return resolvedSyncPromise({});
+ }
+
+ public eventFromMessage(): PromiseLike {
+ return resolvedSyncPromise({});
+ }
+}
+
+let captureCheckIn: ReturnType string>>;
+
+function initClient(options: NestCronMonitoringOptions): void {
+ //@ts-expect-error - just a mock for the test
+ initAndBind(TestClient, {
+ dsn: 'https://username@domain/123',
+ integrations: [],
+ stackParser: () => [],
+ transport: () => createTransport({ recordDroppedEvent: () => undefined }, () => resolvedSyncPromise({})),
+ });
+ const client = SentryCore.getClient() as Client & { captureCheckIn?: unknown };
+ captureCheckIn = vi.fn((_checkIn: SentryCore.CheckIn, _config?: unknown) => 'check-in-id');
+ client.captureCheckIn = captureCheckIn;
+ setCronMonitoringOptions(client, options);
+}
+
+// Drives the `@Cron()` factory channel and returns the decorator `@Cron()` would apply.
+function cron(cronTime: unknown, options?: Record, moduleVersion?: string): MethodDecorator {
+ const data: OrchestrionChannelContext = { arguments: [cronTime, options], moduleVersion };
+ tracingChannel(CHANNELS.NESTJS_SCHEDULE_CRON).traceSync(
+ () => (_target: unknown, _key: unknown, descriptor: PropertyDescriptor) => {
+ Reflect.defineMetadata('SCHEDULE_CRON_OPTIONS', { ...options, cronTime }, descriptor.value);
+ return descriptor;
+ },
+ data,
+ );
+ return data.result as MethodDecorator;
+}
+
+// A decorator that replaces the method and copies its metadata, as Nest needs to still schedule it.
+const wrapMethod: MethodDecorator = (_target, _key, descriptor: PropertyDescriptor) => {
+ const original = descriptor.value as AnyFn;
+ descriptor.value = function (this: unknown, ...args: unknown[]): unknown {
+ return original.apply(this, args);
+ };
+ for (const key of Reflect.getMetadataKeys(original)) {
+ Reflect.defineMetadata(key, Reflect.getMetadata(key, original), descriptor.value);
+ }
+};
+
+function decorate(className: string, decorators: MethodDecorator[]): AnyFn {
+ const target = Object.create({ constructor: { name: className } }) as object;
+ const descriptor: PropertyDescriptor = { value: function run(): void {}, configurable: true };
+ for (const decorator of [...decorators].reverse()) {
+ decorator(target, 'run', descriptor);
+ }
+ return descriptor.value as AnyFn;
+}
+
+// Drives the channel for the function `@nestjs/schedule` passes to `CronJob` as `onTick`, and returns it.
+function cronJobOnTick(method: AnyFn): AnyFn {
+ const data: { arguments: unknown[]; result?: unknown } = { arguments: [method, {}] };
+ tracingChannel<{ arguments: unknown[]; result?: unknown }>(CHANNELS.NESTJS_SCHEDULE_EXPLORER_WRAP).traceSync(
+ () => async () => {},
+ data,
+ );
+ return data.result as AnyFn;
+}
+
+const isMarkedMonitored = (onTick: AnyFn): boolean =>
+ !!(onTick as AnyFn & Record)[Symbol.for('sentry.cron.monitored')];
+
+function sentCheckIns(): Array<[string, string, unknown]> {
+ return captureCheckIn.mock.calls.map(([checkIn, config]) => [checkIn.monitorSlug, checkIn.status, config]);
+}
+
+describe('@Cron() monitoring', () => {
+ beforeEach(() => {
+ subscribeToNestChannels();
+ });
+
+ afterEach(() => {
+ SentryCore.getMainCarrier().__SENTRY__ = undefined;
+ vi.restoreAllMocks();
+ });
+
+ it('sends check-ins with the schedule, using the @Cron() name as slug', () => {
+ initClient({ monitorCronJobs: true });
+ decorate('Jobs', [cron('0 30 9 * * 1-5', { name: 'daily-report', timeZone: 'Europe/Vienna' })])();
+
+ expect(sentCheckIns()).toEqual([
+ [
+ 'daily-report',
+ 'in_progress',
+ { schedule: { type: 'crontab', value: '30 9 * * 1-5' }, timezone: 'Europe/Vienna' },
+ ],
+ ['daily-report', 'ok', undefined],
+ ]);
+ });
+
+ it('uses ClassName.methodName without a name', () => {
+ initClient({ monitorCronJobs: true });
+ decorate('Jobs', [cron('* * * * *', { timeZone: 'UTC' })])();
+
+ expect(sentCheckIns()[0]?.[0]).toBe('Jobs.run');
+ });
+
+ it('sends error check-ins and rethrows', async () => {
+ initClient({ monitorCronJobs: true });
+ vi.spyOn(SentryCore, 'captureException').mockReturnValue('event-id');
+ const target = Object.create({ constructor: { name: 'Jobs' } }) as object;
+ const descriptor: PropertyDescriptor = {
+ value: async function run(): Promise {
+ throw new Error('boom');
+ },
+ };
+ cron('* * * * *', { timeZone: 'UTC' })(target, 'run', descriptor);
+
+ await expect((descriptor.value as AnyFn)()).rejects.toThrow('boom');
+ expect(sentCheckIns().map(([, status]) => status)).toEqual(['in_progress', 'error']);
+ });
+
+ it('starts a new trace for each run', () => {
+ initClient({ monitorCronJobs: true });
+ const traceIds: string[] = [];
+ const run = decorate('Jobs', [
+ cron('* * * * *', { name: 'job', timeZone: 'UTC' }),
+ (_target, _key, descriptor: PropertyDescriptor) => {
+ descriptor.value = () => {
+ traceIds.push(SentryCore.getCurrentScope().getPropagationContext().traceId);
+ };
+ },
+ ]);
+ const bootstrapTraceId = SentryCore.getCurrentScope().getPropagationContext().traceId;
+
+ run();
+ run();
+
+ expect(traceIds).toHaveLength(2);
+ expect(new Set([bootstrapTraceId, ...traceIds]).size).toBe(3);
+ });
+
+ it('skips jobs whose schedule cannot be sent', () => {
+ initClient({ monitorCronJobs: true });
+ decorate('Jobs', [cron('*/10 * * * * *', { name: 'every-10s' })])();
+
+ expect(captureCheckIn).not.toHaveBeenCalled();
+ });
+
+ it('skips a numeric month on @nestjs/schedule 3', () => {
+ initClient({ monitorCronJobs: true });
+ decorate('Jobs', [cron('0 0 1 1 *', { name: 'yearly', timeZone: 'UTC' }, '3.0.4')])();
+
+ expect(captureCheckIn).not.toHaveBeenCalled();
+ });
+
+ it('sends a numeric month from @nestjs/schedule 4', () => {
+ initClient({ monitorCronJobs: true });
+ decorate('Jobs', [cron('0 0 1 1 *', { name: 'yearly', timeZone: 'UTC' }, '4.1.2')])();
+
+ expect(sentCheckIns()[0]).toEqual([
+ 'yearly',
+ 'in_progress',
+ { schedule: { type: 'crontab', value: '0 0 1 1 *' }, timezone: 'UTC' },
+ ]);
+ });
+
+ it('does not monitor by default', () => {
+ initClient({});
+ decorate('Jobs', [cron('* * * * *', { name: 'job' })])();
+
+ expect(captureCheckIn).not.toHaveBeenCalled();
+ });
+
+ it('skips excluded jobs', () => {
+ initClient({ monitorCronJobs: true, excludeCronJobs: ['job', /^Other\./] });
+ decorate('Jobs', [cron('* * * * *', { name: 'job' })])();
+ decorate('Other', [cron('* * * * *')])();
+ decorate('Jobs', [cron('* * * * *', { name: 'job-2' })])();
+
+ expect(sentCheckIns().map(([slug]) => slug)).toEqual(['job-2', 'job-2']);
+ });
+
+ it('skips one-off jobs', () => {
+ initClient({ monitorCronJobs: true });
+ decorate('Jobs', [cron(new Date(), { name: 'once' })])();
+
+ expect(captureCheckIn).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ['@Cron() above @SentryCron()', (c: MethodDecorator, s: MethodDecorator) => [c, s]],
+ ['@SentryCron() above @Cron()', (c: MethodDecorator, s: MethodDecorator) => [s, c]],
+ ['@Cron(), another decorator, @SentryCron()', (c: MethodDecorator, s: MethodDecorator) => [c, wrapMethod, s]],
+ ['@SentryCron(), another decorator, @Cron()', (c: MethodDecorator, s: MethodDecorator) => [s, wrapMethod, c]],
+ ['another decorator, @Cron(), @SentryCron()', (c: MethodDecorator, s: MethodDecorator) => [wrapMethod, c, s]],
+ ['another decorator, @SentryCron(), @Cron()', (c: MethodDecorator, s: MethodDecorator) => [wrapMethod, s, c]],
+ ])('only sends the @SentryCron() check-ins with %s', async (_, order) => {
+ initClient({ monitorCronJobs: true });
+ await decorate('Jobs', order(cron('* * * * *', { name: 'job' }), SentryCron('explicit-slug')))();
+
+ expect(sentCheckIns().map(([slug]) => slug)).toEqual(['explicit-slug', 'explicit-slug']);
+ });
+
+ describe('marks the CronJob onTick as monitored', () => {
+ it('when the method is monitored', () => {
+ initClient({ monitorCronJobs: true });
+ const onTick = cronJobOnTick(decorate('Jobs', [cron('* * * * *', { name: 'job' })]));
+
+ expect(isMarkedMonitored(onTick)).toBe(true);
+ expect(Object.keys(onTick)).toEqual([]);
+ });
+
+ it('when a decorator above @Cron() replaces the method', () => {
+ initClient({ monitorCronJobs: true });
+ const onTick = cronJobOnTick(decorate('Jobs', [wrapMethod, cron('* * * * *', { name: 'job' })]));
+
+ expect(isMarkedMonitored(onTick)).toBe(true);
+ });
+
+ it.each([
+ ['@SentryCron(), another decorator, @Cron()', (c: MethodDecorator, s: MethodDecorator) => [s, wrapMethod, c]],
+ ['another decorator, @SentryCron(), @Cron()', (c: MethodDecorator, s: MethodDecorator) => [wrapMethod, s, c]],
+ ['another decorator, @Cron(), @SentryCron()', (c: MethodDecorator, s: MethodDecorator) => [wrapMethod, c, s]],
+ ])('when the method has %s', (_, order) => {
+ initClient({});
+ const onTick = cronJobOnTick(
+ decorate('Jobs', order(cron('* * * * *', { name: 'job' }), SentryCron('explicit-slug'))),
+ );
+
+ expect(isMarkedMonitored(onTick)).toBe(true);
+ });
+
+ it('not when a decorator above @Cron() replaces an excluded method', () => {
+ initClient({ monitorCronJobs: true, excludeCronJobs: ['job'] });
+ const onTick = cronJobOnTick(decorate('Jobs', [wrapMethod, cron('* * * * *', { name: 'job' })]));
+
+ expect(isMarkedMonitored(onTick)).toBe(false);
+ });
+
+ it('when the method has @SentryCron()', () => {
+ initClient({});
+ const onTick = cronJobOnTick(decorate('Jobs', [cron('* * * * *', { name: 'job' }), SentryCron('explicit-slug')]));
+
+ expect(isMarkedMonitored(onTick)).toBe(true);
+ });
+
+ it.each([
+ ['monitorCronJobs is off', {}],
+ ['the job is excluded', { monitorCronJobs: true, excludeCronJobs: ['job'] }],
+ ])('not when %s', (_, options) => {
+ initClient(options);
+ const onTick = cronJobOnTick(decorate('Jobs', [cron('* * * * *', { name: 'job' })]));
+
+ expect(isMarkedMonitored(onTick)).toBe(false);
+ });
+
+ it('not for other scheduled methods', () => {
+ initClient({ monitorCronJobs: true });
+
+ expect(isMarkedMonitored(cronJobOnTick(function interval(): void {}))).toBe(false);
+ });
+ });
+});
diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json
index 25e7eb4f2f14..5c5ff9db22a5 100644
--- a/packages/nextjs/package.json
+++ b/packages/nextjs/package.json
@@ -87,7 +87,7 @@
"@sentry/browser-utils": "11.10.0",
"@sentry/bundler-plugins": "11.10.0",
"@sentry/cloudflare": "11.10.0",
- "@sentry/conventions": "^0.29.0",
+ "@sentry/conventions": "^0.30.0",
"@sentry/core": "11.10.0",
"@sentry/node": "11.10.0",
"@sentry/opentelemetry": "11.10.0",
diff --git a/packages/nextjs/src/client/browserTracingIntegration.ts b/packages/nextjs/src/client/browserTracingIntegration.ts
index acd961a77c8e..0ca0cbec4a15 100644
--- a/packages/nextjs/src/client/browserTracingIntegration.ts
+++ b/packages/nextjs/src/client/browserTracingIntegration.ts
@@ -1,6 +1,6 @@
import type { Integration } from '@sentry/core';
import { browserTracingIntegration as originalBrowserTracingIntegration, isBotUserAgent } from '@sentry/react';
-import { settlePendingPageloadWait } from './routing/appRouterRoutingInstrumentation';
+import { settlePendingPageloadWait } from './routing/appRouterPageloadInstrumentation';
import { nextRouterInstrumentNavigation, nextRouterInstrumentPageLoad } from './routing/nextRoutingInstrumentation';
/**
diff --git a/packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts
new file mode 100644
index 000000000000..20e7a0e727e0
--- /dev/null
+++ b/packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts
@@ -0,0 +1,112 @@
+import {
+ SENTRY_OP,
+ SENTRY_ORIGIN,
+ SENTRY_SEGMENT_NAME_SOURCE,
+ URL_FULL,
+ URL_PATH,
+ URL_TEMPLATE,
+} from '@sentry/conventions/attributes';
+import { PAGELOAD } from '@sentry/conventions/op';
+import type { Client, Span, StartSpanOptions } from '@sentry/core';
+import {
+ filterCollectedUrl,
+ getCurrentScope,
+ hasSpanStreamingEnabled,
+ PAGELOAD_SPAN_NAME_FALLBACK,
+} from '@sentry/core';
+import { resolveCurrentRoute, startBrowserTracingPageLoadSpan, WINDOW } from '@sentry/react';
+import { stripTrailingSlash } from './parameterization';
+import type { TraceMetaTagValues, TraceMetaTagWait } from './traceMetaTags';
+import {
+ addTraceMetaTagLink,
+ canWaitForTraceMetaTag,
+ readTraceMetaTags,
+ waitForTraceMetaTag,
+ withCacheOrigin,
+} from './traceMetaTags';
+
+/**
+ * How long the pageload start may wait for the resumed part of a Cache Components document. The wait
+ * normally ends with `DOMContentLoaded`; the cap only matters for a response the server keeps open.
+ */
+const PAGELOAD_TRACE_META_TAG_TIMEOUT_MS = 10_000;
+
+let pendingPageloadWait: TraceMetaTagWait | undefined;
+
+/**
+ * Starts a pageload that is still waiting for its trace meta tag with a fresh trace. Called when a
+ * navigation starts, before the navigation span is created, so the navigation ends the pageload like
+ * any pageload it interrupts, and the pageload is still recorded.
+ */
+export function settlePendingPageloadWait(): void {
+ pendingPageloadWait?.giveUp();
+}
+
+/**
+ * Starts the pageload span with the request data of the document. The span may start after a
+ * navigation already changed the location, so the browser SDK would otherwise read the new one.
+ */
+function startPageloadSpan(
+ client: Client,
+ spanOptions: StartSpanOptions,
+ documentUrl: string,
+ traceMetaTags?: TraceMetaTagValues,
+): Span | undefined {
+ const span = startBrowserTracingPageLoadSpan(client, withCacheOrigin(spanOptions, traceMetaTags), traceMetaTags);
+ const scope = getCurrentScope();
+ const { normalizedRequest } = scope.getScopeData().sdkProcessingMetadata;
+ if (normalizedRequest) {
+ scope.setSDKProcessingMetadata({ normalizedRequest: { ...normalizedRequest, url: documentUrl } });
+ }
+ return span;
+}
+
+/** Instruments the Next.js app router for pageloads. */
+export function appRouterInstrumentPageLoad(client: Client): void {
+ const documentUrl = filterCollectedUrl(WINDOW.location.href, client);
+ const pathname = stripTrailingSlash(WINDOW.location.pathname);
+ const parameterizedPathname = resolveCurrentRoute(client);
+ const spanOptions: StartSpanOptions = {
+ // With span streaming, span names have to be low cardinality, so we can't fall back to the URL.
+ name: parameterizedPathname ?? (hasSpanStreamingEnabled(client) ? PAGELOAD_SPAN_NAME_FALLBACK : pathname),
+ // pageload should always start at timeOrigin (and needs to be in s, not ms)
+ attributes: {
+ [SENTRY_OP]: PAGELOAD,
+ [SENTRY_ORIGIN]: 'auto.pageload.nextjs.app_router_instrumentation',
+ [SENTRY_SEGMENT_NAME_SOURCE]: parameterizedPathname ? 'route' : 'url',
+ [URL_PATH]: WINDOW.location.pathname,
+ [URL_FULL]: documentUrl,
+ [URL_TEMPLATE]: parameterizedPathname,
+ },
+ };
+
+ // A tag that is already in the document was rendered for this request and can be continued. A
+ // Cache Components document that resumes a prerendered shell gets its tag with the resumed part,
+ // which usually arrives after the SDK started. Waiting for it lets the pageload continue the server
+ // trace instead of linking it; the span's start is backdated to the time origin either way.
+ const traceMetaTags = readTraceMetaTags();
+ if (traceMetaTags || !canWaitForTraceMetaTag()) {
+ startPageloadSpan(client, spanOptions, documentUrl, traceMetaTags);
+ return;
+ }
+
+ pendingPageloadWait = waitForTraceMetaTag(
+ values => {
+ pendingPageloadWait = undefined;
+ startPageloadSpan(client, spanOptions, documentUrl, values);
+ },
+ () => {
+ pendingPageloadWait = undefined;
+ const span = startPageloadSpan(client, spanOptions, documentUrl);
+ // Only reachable through the cap or a navigation while the document still streams: a tag that
+ // shows up after this can no longer be continued, but the server request can still be linked.
+ if (span) {
+ waitForTraceMetaTag(
+ values => span.isRecording() && addTraceMetaTagLink(span, values),
+ () => undefined,
+ );
+ }
+ },
+ PAGELOAD_TRACE_META_TAG_TIMEOUT_MS,
+ );
+}
diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts
index 108637e5de2e..f2f933bc11bf 100644
--- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts
+++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts
@@ -1,25 +1,20 @@
-import type { Client, Span, StartSpanOptions } from '@sentry/core';
+import type { Client, Span } from '@sentry/core';
import {
- getCurrentScope,
GLOBAL_OBJ,
hasSpanStreamingEnabled,
isObjectLike,
NAVIGATION_SPAN_NAME_FALLBACK,
- PAGELOAD_SPAN_NAME_FALLBACK,
filterCollectedUrl,
timestampInSeconds,
} from '@sentry/core';
import {
startBrowserTracingNavigationSpan,
- startBrowserTracingPageLoadSpan,
WINDOW,
getAbsoluteUrl,
resolveCurrentRoute,
resolveRoute,
} from '@sentry/react';
import { stripTrailingSlash } from './parameterization';
-import type { TraceMetaTagValues, TraceMetaTagWait } from './traceMetaTags';
-import { addTraceMetaTagLink, canWaitForTraceMetaTag, readTraceMetaTags, waitForTraceMetaTag } from './traceMetaTags';
import {
SENTRY_OP,
SENTRY_SEGMENT_NAME_SOURCE,
@@ -27,8 +22,9 @@ import {
URL_PATH,
URL_TEMPLATE,
SENTRY_ORIGIN,
+ NAVIGATION_TYPE,
} from '@sentry/conventions/attributes';
-import { NAVIGATION, PAGELOAD } from '@sentry/conventions/op';
+import { NAVIGATION } from '@sentry/conventions/op';
function setNavigationSpanUrlAttributes(span: Span, urlPath: string, urlOrPath: string): void {
span.setAttributes({
@@ -98,92 +94,6 @@ let navigationRoutingMode: 'router-patch' | 'transition-start-hook' = 'router-pa
const currentRouterPatchingNavigationSpanRef: NavigationSpanRef = { current: undefined };
-/**
- * How long the pageload start may wait for the resumed part of a Cache Components document. The wait
- * normally ends with `DOMContentLoaded`; the cap only matters for a response the server keeps open.
- */
-const PAGELOAD_TRACE_META_TAG_TIMEOUT_MS = 10_000;
-
-let pendingPageloadWait: TraceMetaTagWait | undefined;
-
-/**
- * Starts a pageload that is still waiting for its trace meta tag with a fresh trace. Called when a
- * navigation starts, before the navigation span is created, so the navigation ends the pageload like
- * any pageload it interrupts, and the pageload is still recorded.
- */
-export function settlePendingPageloadWait(): void {
- pendingPageloadWait?.giveUp();
-}
-
-/**
- * Starts the pageload span with the request data of the document. The span may start after a
- * navigation already changed the location, so the browser SDK would otherwise read the new one.
- */
-function startPageloadSpan(
- client: Client,
- spanOptions: StartSpanOptions,
- documentUrl: string,
- traceMetaTags?: TraceMetaTagValues,
-): Span | undefined {
- const span = startBrowserTracingPageLoadSpan(client, spanOptions, traceMetaTags);
- const scope = getCurrentScope();
- const { normalizedRequest } = scope.getScopeData().sdkProcessingMetadata;
- if (normalizedRequest) {
- scope.setSDKProcessingMetadata({ normalizedRequest: { ...normalizedRequest, url: documentUrl } });
- }
- return span;
-}
-
-/** Instruments the Next.js app router for pageloads. */
-export function appRouterInstrumentPageLoad(client: Client): void {
- const documentUrl = filterCollectedUrl(WINDOW.location.href, client);
- const pathname = stripTrailingSlash(WINDOW.location.pathname);
- const parameterizedPathname = resolveCurrentRoute(client);
- const spanOptions: StartSpanOptions = {
- // With span streaming, span names have to be low cardinality, so we can't fall back to the URL.
- name: parameterizedPathname ?? (hasSpanStreamingEnabled(client) ? PAGELOAD_SPAN_NAME_FALLBACK : pathname),
- // pageload should always start at timeOrigin (and needs to be in s, not ms)
- attributes: {
- [SENTRY_OP]: PAGELOAD,
- [SENTRY_ORIGIN]: 'auto.pageload.nextjs.app_router_instrumentation',
- [SENTRY_SEGMENT_NAME_SOURCE]: parameterizedPathname ? 'route' : 'url',
- [URL_PATH]: WINDOW.location.pathname,
- [URL_FULL]: documentUrl,
- ...(parameterizedPathname && { [URL_TEMPLATE]: parameterizedPathname }),
- },
- };
-
- // A tag that is already in the document was rendered for this request and can be continued. A
- // Cache Components document that resumes a prerendered shell gets its tag with the resumed part,
- // which usually arrives after the SDK started. Waiting for it lets the pageload continue the server
- // trace instead of linking it; the span's start is backdated to the time origin either way.
- const traceMetaTags = readTraceMetaTags();
- if (traceMetaTags || !canWaitForTraceMetaTag()) {
- startPageloadSpan(client, spanOptions, documentUrl, traceMetaTags);
- return;
- }
-
- pendingPageloadWait = waitForTraceMetaTag(
- values => {
- pendingPageloadWait = undefined;
- startPageloadSpan(client, spanOptions, documentUrl, values);
- },
- () => {
- pendingPageloadWait = undefined;
- const span = startPageloadSpan(client, spanOptions, documentUrl);
- // Only reachable through the cap or a navigation while the document still streams: a tag that
- // shows up after this can no longer be continued, but the server request can still be linked.
- if (span) {
- waitForTraceMetaTag(
- values => span.isRecording() && addTraceMetaTagLink(span, values),
- () => undefined,
- );
- }
- },
- PAGELOAD_TRACE_META_TAG_TIMEOUT_MS,
- );
-}
-
interface NavigationSpanRef {
current: Span | undefined;
}
@@ -239,7 +149,8 @@ export function appRouterInstrumentNavigation(client: Client): void {
if (currentNavigationSpan) {
currentNavigationSpan.updateName(spanName);
currentNavigationSpan.setAttributes({
- 'navigation.type': `router.${navigationType}`,
+ // oxlint-disable-next-line typescript/no-deprecated -- Preserve the existing attribute name.
+ [NAVIGATION_TYPE]: `router.${navigationType}`,
[SENTRY_SEGMENT_NAME_SOURCE]: parameterizedPathname ? 'route' : 'url',
...(parameterizedPathname && { [URL_TEMPLATE]: parameterizedPathname }),
});
@@ -254,8 +165,9 @@ export function appRouterInstrumentNavigation(client: Client): void {
[SENTRY_OP]: NAVIGATION,
[SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation',
[SENTRY_SEGMENT_NAME_SOURCE]: parameterizedPathname ? 'route' : 'url',
- 'navigation.type': `router.${navigationType}`,
- ...(parameterizedPathname && { [URL_TEMPLATE]: parameterizedPathname }),
+ // oxlint-disable-next-line typescript/no-deprecated -- Preserve the existing attribute name.
+ [NAVIGATION_TYPE]: `router.${navigationType}`,
+ [URL_TEMPLATE]: parameterizedPathname,
},
},
{ url: getAbsoluteUrl(normalizedHref) },
@@ -291,8 +203,9 @@ export function appRouterInstrumentNavigation(client: Client): void {
attributes: {
[SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation',
[SENTRY_SEGMENT_NAME_SOURCE]: parameterizedPathname ? 'route' : 'url',
- 'navigation.type': traversal?.navigationType ?? 'browser.popstate',
- ...(parameterizedPathname && { [URL_TEMPLATE]: parameterizedPathname }),
+ // oxlint-disable-next-line typescript/no-deprecated -- Preserve the existing attribute name.
+ [NAVIGATION_TYPE]: traversal?.navigationType ?? 'browser.popstate',
+ [URL_TEMPLATE]: parameterizedPathname,
},
},
// The full location rather than just the pathname, so the span's `url.full` keeps the
@@ -388,8 +301,9 @@ function patchRouter(client: Client, router: NextRouter, currentNavigationSpanRe
[SENTRY_OP]: NAVIGATION,
[SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation',
[SENTRY_SEGMENT_NAME_SOURCE]: parameterizedPathname ? 'route' : 'url',
- 'navigation.type': `router.${routerFunctionName}`,
- ...(parameterizedPathname && { [URL_TEMPLATE]: parameterizedPathname }),
+ // oxlint-disable-next-line typescript/no-deprecated -- Preserve the existing attribute name.
+ [NAVIGATION_TYPE]: `router.${routerFunctionName}`,
+ [URL_TEMPLATE]: parameterizedPathname,
},
},
{ url: getAbsoluteUrl(normalizedHref) },
diff --git a/packages/nextjs/src/client/routing/nextRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/nextRoutingInstrumentation.ts
index f9a31f3f0e79..e453ac6e6871 100644
--- a/packages/nextjs/src/client/routing/nextRoutingInstrumentation.ts
+++ b/packages/nextjs/src/client/routing/nextRoutingInstrumentation.ts
@@ -1,6 +1,7 @@
import type { Client } from '@sentry/core';
import { WINDOW } from '@sentry/react';
-import { appRouterInstrumentNavigation, appRouterInstrumentPageLoad } from './appRouterRoutingInstrumentation';
+import { appRouterInstrumentNavigation } from './appRouterRoutingInstrumentation';
+import { appRouterInstrumentPageLoad } from './appRouterPageloadInstrumentation';
import { pagesRouterInstrumentNavigation } from './pagesRouterNavigationInstrumentation';
import { pagesRouterInstrumentPageLoad } from './pagesRouterRoutingInstrumentation';
diff --git a/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts
index 7335d0465bda..c37599e69fcb 100644
--- a/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts
+++ b/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts
@@ -109,7 +109,7 @@ export function pagesRouterInstrumentPageLoad(client: Client): void {
[SENTRY_OP]: PAGELOAD,
[SENTRY_ORIGIN]: 'auto.pageload.nextjs.pages_router_instrumentation',
[SENTRY_SEGMENT_NAME_SOURCE]: route ? 'route' : 'url',
- ...(route && { [URL_TEMPLATE]: route }),
+ [URL_TEMPLATE]: route,
...(params && { ...params }),
},
},
diff --git a/packages/nextjs/src/client/routing/traceMetaTags.ts b/packages/nextjs/src/client/routing/traceMetaTags.ts
index 8e1138000ec5..0d85189e8b4b 100644
--- a/packages/nextjs/src/client/routing/traceMetaTags.ts
+++ b/packages/nextjs/src/client/routing/traceMetaTags.ts
@@ -1,7 +1,7 @@
import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes';
-import type { Span } from '@sentry/core';
-import { extractTraceparentData } from '@sentry/core';
-import { GLOBAL_OBJ } from '@sentry/core';
+import type { Span, SpanLink, StartSpanOptions } from '@sentry/core';
+import { extractTraceparentData, GLOBAL_OBJ } from '@sentry/core';
+import { CACHE_ORIGIN_META_TAG_NAME } from '../../common/traceMetaTags';
import { WINDOW } from '@sentry/react';
// `@opentelemetry/api` `TraceFlags`, inlined because core does not export its copies.
@@ -9,12 +9,16 @@ const TRACE_FLAG_SAMPLED = 0x1;
const TRACE_FLAG_NONE = 0x0;
const SERVER_REQUEST_LINK_TYPE = 'server_request';
+const CACHE_ORIGIN_LINK_TYPE = 'cache_origin';
export interface TraceMetaTagValues {
sentryTrace: string;
baggage: string | undefined;
}
+// Not in `@sentry/conventions` yet: https://github.com/getsentry/sentry-conventions/pull/740
+const PAGE_SERVER_RENDERING_TYPE = 'page.server_rendering.type';
+
function getDocument(): Document | undefined {
return WINDOW.document;
}
@@ -131,22 +135,65 @@ export function waitForTraceMetaTag(
return { giveUp: () => settle(onGiveUp) };
}
-/**
- * Adds a span link to the span described by a `sentry-trace` value. Used when the tag arrives after
- * the pageload span exists, which can only link the server request, not continue it.
- */
-export function addTraceMetaTagLink(span: Span, { sentryTrace }: TraceMetaTagValues): void {
+/** The span link to the span a `sentry-trace` value describes, if the value is one. */
+function traceMetaTagLink(sentryTrace: string, linkType: string): SpanLink | undefined {
const traceparent = extractTraceparentData(sentryTrace);
if (!traceparent?.traceId || !traceparent.parentSpanId) {
- return;
+ return undefined;
}
-
- span.addLink({
+ return {
context: {
traceId: traceparent.traceId,
spanId: traceparent.parentSpanId,
traceFlags: traceparent.parentSampled ? TRACE_FLAG_SAMPLED : TRACE_FLAG_NONE,
},
- attributes: { [SENTRY_LINK_TYPE]: SERVER_REQUEST_LINK_TYPE },
- });
+ attributes: { [SENTRY_LINK_TYPE]: linkType },
+ };
+}
+
+/**
+ * Adds a span link to the span described by a `sentry-trace` value. Used when the tag arrives after
+ * the pageload span exists, which can only link the server request, not continue it.
+ */
+export function addTraceMetaTagLink(span: Span, { sentryTrace }: TraceMetaTagValues): void {
+ const link = traceMetaTagLink(sentryTrace, SERVER_REQUEST_LINK_TYPE);
+ if (link) {
+ span.addLink(link);
+ }
+}
+
+/**
+ * How the server rendered a document with a `sentry-cache-origin` tag. With trace meta tags of this
+ * request, only the shell was reused. Unset while a resumed part can still bring those tags.
+ */
+function getServerRenderingType(traceMetaTags: TraceMetaTagValues | undefined): string | undefined {
+ if (traceMetaTags) {
+ return 'partially_static';
+ }
+ return canWaitForTraceMetaTag() ? undefined : 'static';
+}
+
+/**
+ * Marks a pageload whose document carries a `sentry-cache-origin` tag with how the server rendered
+ * it, and links the render that the page reuses. Applied at span start, so `tracesSampler` and
+ * `ignoreSpans` see both. Like a `use cache` hit, the pageload links only a sampled render.
+ */
+export function withCacheOrigin(spanOptions: StartSpanOptions, traceMetaTags?: TraceMetaTagValues): StartSpanOptions {
+ const document = getDocument();
+ const cacheOrigin = document && lastMetaContent(document, CACHE_ORIGIN_META_TAG_NAME);
+ if (!cacheOrigin) {
+ return spanOptions;
+ }
+
+ const link = traceMetaTagLink(cacheOrigin, CACHE_ORIGIN_LINK_TYPE);
+ const isSampled = link?.context.traceFlags === TRACE_FLAG_SAMPLED;
+ const serverRenderingType = getServerRenderingType(traceMetaTags);
+ return {
+ ...spanOptions,
+ attributes: {
+ ...spanOptions.attributes,
+ ...(serverRenderingType && { [PAGE_SERVER_RENDERING_TYPE]: serverRenderingType }),
+ },
+ links: link && isSampled ? [...(spanOptions.links ?? []), link] : spanOptions.links,
+ };
}
diff --git a/packages/nextjs/src/cloudflare/index.ts b/packages/nextjs/src/cloudflare/index.ts
index 9c43cff4b5a3..5c8b65007cd4 100644
--- a/packages/nextjs/src/cloudflare/index.ts
+++ b/packages/nextjs/src/cloudflare/index.ts
@@ -11,12 +11,17 @@ import { isTunnelRouteSpan } from '../common/utils/dropMiddlewareTunnelRequests'
import { dropReactControlFlowErrorsEventProcessor } from '../common/utils/dropReactControlFlowErrors';
import { markAsyncContextOwnedByNextjsCloudflare } from '../common/utils/responseEnd';
import { NextSentryPropagator } from '../server/nextSentryPropagator';
+import { prepareSafeIdGeneratorContext } from '../server/prepareSafeIdGeneratorContext';
import { getGlobalTracerProvider, markPrerendersOnTracerProvider } from '../server/prerenderContext';
import { addNextjsServerSpanHooks, NEXTJS_SERVER_IGNORE_SPANS } from '../server/serverSpanHooks';
import { nextjsUseCacheIntegration } from '../server/useCacheInstrumentation';
export * from '@sentry/cloudflare';
+// At isolate startup, so the copy of `@sentry/core` in the Worker bundle finds the runner on its first ID. A snapshot
+// taken here is not bound to a request, unlike one taken while OpenNext loads Next.js inside the first request.
+prepareSafeIdGeneratorContext();
+
class NextjsCloudflarePropagator extends NextSentryPropagator {
/** @inheritDoc */
public extract(ctx: Context, carrier: unknown, getter: TextMapGetter): Context {
diff --git a/packages/nextjs/src/common/traceMetaTags.ts b/packages/nextjs/src/common/traceMetaTags.ts
new file mode 100644
index 000000000000..90ae909bf962
--- /dev/null
+++ b/packages/nextjs/src/common/traceMetaTags.ts
@@ -0,0 +1,6 @@
+/**
+ * Name of the meta tag the server writes in place of `sentry-trace` while Next.js prerenders a document.
+ * Its content is the `sentry-trace` value of the prerender. The client turns it into a `cache_origin`
+ * link and never continues it, because the document is served to every later visitor.
+ */
+export const CACHE_ORIGIN_META_TAG_NAME = 'sentry-cache-origin';
diff --git a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts
index f2d4774f3c54..8baa168fa031 100644
--- a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts
+++ b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts
@@ -34,8 +34,10 @@ export function dropMiddlewareTunnelRequests(
return;
}
- // Only filter middleware spans, HTTP fetch spans, or BaseServer.handleRequest spans
- const isMiddleware = attrs?.[ATTR_NEXT_SPAN_TYPE] === 'Middleware.execute';
+ // Only filter middleware spans, HTTP fetch spans, or BaseServer.handleRequest spans. Since Next.js 16.4.3, a proxy
+ // invoked separately from the render runs under a `Next.request` root span, which has to be dropped too.
+ const isMiddleware =
+ attrs?.[ATTR_NEXT_SPAN_TYPE] === 'Middleware.execute' || attrs?.[ATTR_NEXT_SPAN_TYPE] === 'Next.request';
// The fetch span could be originating from rewrites re-writing a tunnel request
// So we want to filter it out
const isFetchSpan = attrs?.[SENTRY_ORIGIN] === 'auto.http.node_fetch';
@@ -114,8 +116,18 @@ export function getTunnelRouteIgnoreSpans(): NonNullable> | undefined,
): void {
const spanType = spanAttributes?.[ATTR_NEXT_SPAN_TYPE];
- if (spanType !== 'BaseServer.handleRequest' && spanType !== 'Middleware.execute') {
+ if (spanType !== 'BaseServer.handleRequest' && spanType !== 'Middleware.execute' && spanType !== 'Next.request') {
return;
}
diff --git a/packages/nextjs/src/config/handleRunAfterProductionCompile.ts b/packages/nextjs/src/config/handleRunAfterProductionCompile.ts
index 5d21f2653b3a..a9dafeaeac86 100644
--- a/packages/nextjs/src/config/handleRunAfterProductionCompile.ts
+++ b/packages/nextjs/src/config/handleRunAfterProductionCompile.ts
@@ -137,7 +137,8 @@ async function warnAboutUncoveredSourcemaps(
}
}
-const SOURCEMAPPING_URL_COMMENT_REGEX = /(?:^|\n)\/\/[#@] sourceMappingURL=[^\s'"`*]+\s*$/;
+const SOURCEMAPPING_URL_COMMENT_REGEX =
+ /(?:^|\r?\n)\/\/[#@] sourceMappingURL=[^\s'"`*]+\s*?(?=(?:\r?\n\/\/# debugId=[\da-fA-F-]+\s*)?$)/;
const CSS_SOURCEMAPPING_URL_COMMENT_REGEX = /\n?\/\*[#@] sourceMappingURL=[^\s*]+\s*\*\/\s*$/;
/**
diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts
index 391dee0e39f1..da0ca895c246 100644
--- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts
+++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts
@@ -65,22 +65,25 @@ export function constructTurbopackConfig({
newConfig.rules = safelyAddTurbopackRule(newConfig.rules, { matcher, rule });
}
- // Add module metadata injection loader for thirdPartyErrorFilterIntegration support.
- // This loader tags modules with `_sentryModuleMetadata` so the integration can tell
- // first-party code from third-party code. We intentionally do NOT use
- // `condition: { not: 'foreign' }` so that node_modules are also tagged as first-party,
- // matching the webpack plugin's BannerPlugin behavior (which injects into all chunks
- // without excluding node_modules). The injected code is wrapped in a try-catch IIFE
- // so it is safe even for node_modules with strict initialization order.
- // We only exclude Next.js build polyfills which contain non-standard syntax that causes
- // parse errors when any code is prepended (Turbopack re-parses the loader output).
+ // Tag modules so thirdPartyErrorFilterIntegration can identify first-party code. Dependencies
+ // are tagged by default to match webpack's BannerPlugin, which injects metadata into all chunks.
+ // Opting out with `applicationKeyExcludeDependencies` can leave dependency-only chunks untagged.
+ // The injected code is wrapped in a try-catch IIFE, so it is safe even for dependencies with
+ // strict initialization order. Next.js build polyfills are excluded because they contain
+ // non-standard syntax that causes parse errors when Turbopack re-parses the loader output.
// Server and edge modules are skipped because only the browser reads these tags.
const applicationKey = userSentryOptions?.applicationKey;
if (applicationKey && nextJsVersion && supportsTurbopackRuleCondition(nextJsVersion)) {
newConfig.rules = safelyAddTurbopackRule(newConfig.rules, {
matcher: '*.{ts,tsx,js,jsx,mjs,cjs}',
rule: {
- condition: { all: ['browser', { not: { path: /next\/dist\/build\/polyfills/ } }] },
+ condition: {
+ all: [
+ 'browser',
+ { not: { path: /next\/dist\/build\/polyfills/ } },
+ ...(userSentryOptions?.applicationKeyExcludeDependencies ? [{ not: 'foreign' as const }] : []),
+ ],
+ },
loaders: [
{
loader: path.resolve(__dirname, '..', 'loaders', 'moduleMetadataInjectionLoader.js'),
diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts
index 4e1e6bc2d9e3..7c990a480e51 100644
--- a/packages/nextjs/src/config/types.ts
+++ b/packages/nextjs/src/config/types.ts
@@ -225,6 +225,21 @@ export type SentryBuildOptions = Omit<
*/
applicationKey?: string;
+ /**
+ * Skip application key metadata injection for dependencies in Turbopack builds with Next.js 16+.
+ * Uses Turbopack's `foreign` condition, which includes `node_modules` and some Next.js internals.
+ * Requires `applicationKey`. Has no effect on webpack builds.
+ *
+ * This reduces per-module injection overhead, but chunks containing only dependencies may no
+ * longer be tagged as first-party. Only use with `*-exclusively-contains-third-party-frames`
+ * behaviours of `thirdPartyErrorFilterIntegration`, and accept that errors whose stacks are
+ * entirely in untagged chunks can be dropped or tagged as third-party. With `*-contains-*`
+ * behaviours, even one frame from an untagged chunk can cause this misclassification.
+ *
+ * @default false
+ */
+ applicationKeyExcludeDependencies?: boolean;
+
/**
* Metadata that should be associated with the built application.
*
diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts
index e855041202e1..6a2e4b164bb9 100644
--- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts
+++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts
@@ -1,6 +1,8 @@
+import { getBuildLogger } from '../buildLogger';
import type { NextConfigObject, SentryBuildOptions } from '../types';
import { getNextjsVersion } from '../util';
import { setUpBuildTimeVariables } from './buildTime';
+import type { BundlerInfo } from './getFinalConfigObjectBundlerUtils';
import {
getBundlerInfo,
getServerExternalPackagesPatch,
@@ -61,6 +63,7 @@ export function getFinalConfigObject(
const bundlerInfo = getBundlerInfo(nextJsVersion);
maybeWarnAboutUnsupportedTurbopack(nextJsVersion, bundlerInfo, userSentryOptions.silent);
maybeWarnAboutTurbopackModuleMetadata(userSentryOptions, bundlerInfo);
+ maybeWarnAboutApplicationKeyExcludeDependencies(userSentryOptions, bundlerInfo);
maybeWarnAboutUnsupportedRunAfterProductionCompileHook(nextJsVersion, userSentryOptions, bundlerInfo);
const turboPackConfig = maybeConstructTurbopackConfig(
@@ -108,3 +111,23 @@ export function getFinalConfigObject(
...getTurbopackPatch(bundlerInfo, turboPackConfig),
};
}
+
+function maybeWarnAboutApplicationKeyExcludeDependencies(
+ userSentryOptions: SentryBuildOptions,
+ bundlerInfo: BundlerInfo,
+): void {
+ if (!userSentryOptions.applicationKeyExcludeDependencies) {
+ return;
+ }
+
+ const logger = getBuildLogger(userSentryOptions.silent);
+ if (!userSentryOptions.applicationKey) {
+ logger.warn(
+ '[@sentry/nextjs] `applicationKeyExcludeDependencies` requires `applicationKey` and has no effect without it.',
+ );
+ } else if (bundlerInfo.isWebpack) {
+ logger.warn(
+ '[@sentry/nextjs] `applicationKeyExcludeDependencies` only applies to Turbopack builds and has no effect on webpack builds.',
+ );
+ }
+}
diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts
index 7ca66c51d1f7..a90529b8d24d 100644
--- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts
+++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts
@@ -1,3 +1,4 @@
+import { CACHE_ORIGIN_META_TAG_NAME } from '../../common/traceMetaTags';
import { debug, isMatchingPattern, parseSemver } from '@sentry/core';
import { getSentryRelease } from '@sentry/node';
import * as fs from 'fs';
@@ -194,12 +195,13 @@ export function maybeSetClientTraceMetadataOption(
incomingUserNextConfigObject.experimental.clientTraceMetadata = [
'baggage',
'sentry-trace',
+ CACHE_ORIGIN_META_TAG_NAME,
...(incomingUserNextConfigObject.experimental?.clientTraceMetadata || []),
];
}
} else {
getBuildLogger(silent).log(
- "[@sentry/nextjs] The Sentry SDK was not able to determine your Next.js version. If you are using Next.js version 15 or greater, please add `experimental.clientTraceMetadata: ['sentry-trace', 'baggage']` to your Next.js config to enable pageload tracing for App Router.",
+ "[@sentry/nextjs] The Sentry SDK was not able to determine your Next.js version. If you are using Next.js version 15 or greater, please add `experimental.clientTraceMetadata: ['sentry-trace', 'baggage', 'sentry-cache-origin']` to your Next.js config to enable pageload tracing for App Router.",
);
}
}
diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts
index e479fa45bd47..132d5f50aef0 100644
--- a/packages/nextjs/src/edge/index.ts
+++ b/packages/nextjs/src/edge/index.ts
@@ -148,7 +148,7 @@ export function init(options: VercelEdgeOptions = {}): void {
// `normalizedRequest` on the isolation scope. Seed it from span attributes so `tracesSampler` has access.
client.on('beforeSampling', ({ spanAttributes }) => {
const spanType = spanAttributes[ATTR_NEXT_SPAN_TYPE];
- if (spanType !== 'Middleware.execute' && spanType !== 'BaseServer.handleRequest') {
+ if (spanType !== 'Middleware.execute' && spanType !== 'BaseServer.handleRequest' && spanType !== 'Next.request') {
return;
}
diff --git a/packages/nextjs/src/server/nextSentryPropagator.ts b/packages/nextjs/src/server/nextSentryPropagator.ts
index 1fd3c3438307..b583beff3465 100644
--- a/packages/nextjs/src/server/nextSentryPropagator.ts
+++ b/packages/nextjs/src/server/nextSentryPropagator.ts
@@ -1,12 +1,14 @@
import type { Context, TextMapSetter, TracerProvider } from '@opentelemetry/api';
-import { propagation } from '@opentelemetry/api';
+import { defaultTextMapSetter, propagation } from '@opentelemetry/api';
import { SentryPropagator } from '@sentry/opentelemetry';
+import { CACHE_ORIGIN_META_TAG_NAME } from '../common/traceMetaTags';
import { isPrerenderContext, markPrerendersOnTracerProvider } from './prerenderContext';
/**
* A `SentryPropagator` that does not hand out the trace meta tags of a document while Next.js
* prerenders it. Trace context baked into a prerendered shell would be replayed to every visitor the
- * shell is served to, so the browser could continue a trace that never handled its request.
+ * shell is served to, so the browser could continue a trace that never handled its request. The shell
+ * gets the prerender's trace in a `sentry-cache-origin` tag instead, which the client only links.
*
* The SDK registers it when it sets up OpenTelemetry. An app that sets up OpenTelemetry itself
* registers it in place of `SentryPropagator` and calls `markPrerendersOnTracerProvider` with its
@@ -17,10 +19,17 @@ export class NextSentryPropagator extends SentryPropagator {
public inject(ctx: Context, carrier: unknown, setter: TextMapSetter): void {
// Next.js collects the meta tag values into an array; every other caller, such as the outgoing
// request instrumentations, passes a headers object and keeps propagating during a prerender.
- if (Array.isArray(carrier) && isPrerenderContext(ctx)) {
+ if (!Array.isArray(carrier) || !isPrerenderContext(ctx)) {
+ super.inject(ctx, carrier, setter);
return;
}
- super.inject(ctx, carrier, setter);
+
+ const values: Record = {};
+ super.inject(ctx, values, defaultTextMapSetter);
+ const sentryTrace = values['sentry-trace'];
+ if (sentryTrace) {
+ setter.set(carrier, CACHE_ORIGIN_META_TAG_NAME, sentryTrace);
+ }
}
}
diff --git a/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts b/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts
index 7bef06fe211b..08346e4de857 100644
--- a/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts
+++ b/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts
@@ -5,6 +5,14 @@ import { DEBUG_BUILD } from '../common/debug-build';
// Avoids conflict with resolving it from getBuiltinModule
type OriginalAsyncLocalStorage = typeof AsyncLocalStorage;
+interface WorkUnitAsyncStorage {
+ exit(callback: () => T): T;
+}
+
+// Next.js >= 16.4 anchors its async storages to `globalThis` under `Symbol.for('@next/@')`
+// (`next/src/server/app-render/async-local-storage.ts`).
+const NEXT_WORK_UNIT_ASYNC_STORAGE_KEY_PREFIX = '@next/work-unit-async-storage@';
+
/**
* Prepares the global object to generate safe random IDs in cache components contexts
* See: https://github.com/getsentry/sentry-javascript/blob/ceb003c15973c2d8f437dfb7025eedffbc8bc8b0/packages/core/src/utils/propagationContext.ts#L1
@@ -13,9 +21,11 @@ export function prepareSafeIdGeneratorContext(): void {
const sym = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__');
const globalWithSymbol: typeof GLOBAL_OBJ & { [sym]?: RandomSafeContextRunner } = GLOBAL_OBJ;
- // Get initial snapshot - if unavailable, don't set up the wrapper at all
+ let workUnitAsyncStorage = getNextWorkUnitAsyncStorage();
+
+ // Without a snapshot or the Next.js storage there is nothing to run the callback in, so don't set up the wrapper
const initialSnapshot = getAsyncLocalStorageSnapshot();
- if (!initialSnapshot) {
+ if (!initialSnapshot && !workUnitAsyncStorage) {
return;
}
@@ -23,9 +33,19 @@ export function prepareSafeIdGeneratorContext(): void {
// environments (e.g., Cloudflare Workers), the snapshot is bound to the request
// context it was created in. Once that request ends, the snapshot becomes invalid.
// The wrapper catches this and creates a fresh snapshot for the current request context.
- let cachedSnapshot: RandomSafeContextRunner = initialSnapshot;
+ let cachedSnapshot = initialSnapshot;
globalWithSymbol[sym] = (callback: () => T): T => {
+ // Next.js only flags sync IO while its work unit store is active, so exiting that store is enough and works in
+ // any request. A snapshot is only safe when taken outside of a render and, on Cloudflare Workers, only in the
+ // request that took it (`bind_asynclocalstorage_snapshot_to_request`).
+ if (workUnitAsyncStorage) {
+ return workUnitAsyncStorage.exit(callback);
+ }
+ if (!cachedSnapshot) {
+ return callback();
+ }
+
try {
return cachedSnapshot(callback);
} catch (error) {
@@ -34,6 +54,11 @@ export function prepareSafeIdGeneratorContext(): void {
throw error;
}
+ workUnitAsyncStorage = getNextWorkUnitAsyncStorage();
+ if (workUnitAsyncStorage) {
+ return workUnitAsyncStorage.exit(callback);
+ }
+
// Snapshot likely stale, try to get a fresh one and retry
const freshSnapshot = getAsyncLocalStorageSnapshot();
// No snapshot available, fall back to direct execution
@@ -61,6 +86,29 @@ export function prepareSafeIdGeneratorContext(): void {
DEBUG_BUILD && debug.log('[@sentry/nextjs] Prepared safe random ID generator context');
}
+function getNextWorkUnitAsyncStorage(): WorkUnitAsyncStorage | undefined {
+ const globalWithStorages = GLOBAL_OBJ as typeof GLOBAL_OBJ & Record;
+
+ for (const key of Object.getOwnPropertySymbols(globalWithStorages)) {
+ if (!key.description?.startsWith(NEXT_WORK_UNIT_ASYNC_STORAGE_KEY_PREFIX)) {
+ continue;
+ }
+ const storage = globalWithStorages[key] as WorkUnitAsyncStorage | undefined;
+ if (typeof storage?.exit !== 'function') {
+ continue;
+ }
+ try {
+ // Where no AsyncLocalStorage exists, Next.js registers a `FakeAsyncLocalStorage` whose `exit` always throws
+ storage.exit(() => undefined);
+ return storage;
+ } catch {
+ // not usable
+ }
+ }
+
+ return undefined;
+}
+
function getAsyncLocalStorage(): OriginalAsyncLocalStorage | undefined {
// May exist in the Next.js runtime globals
// Doesn't exist in some of our tests
diff --git a/packages/nextjs/src/server/vercelQueuesMonitoring.ts b/packages/nextjs/src/server/vercelQueuesMonitoring.ts
index e4216afd6c88..977046b79045 100644
--- a/packages/nextjs/src/server/vercelQueuesMonitoring.ts
+++ b/packages/nextjs/src/server/vercelQueuesMonitoring.ts
@@ -1,12 +1,15 @@
-import { URL_FULL } from '@sentry/conventions/attributes';
+import {
+ URL_FULL,
+ MESSAGING_DESTINATION_NAME,
+ MESSAGING_MESSAGE_ID,
+ MESSAGING_OPERATION_NAME,
+ MESSAGING_SYSTEM,
+} from '@sentry/conventions/attributes';
import type { Span } from '@sentry/core';
import { getIsolationScope, spanToJSON } from '@sentry/core';
// OTel Messaging semantic convention attribute keys
-const ATTR_MESSAGING_SYSTEM = 'messaging.system';
-const ATTR_MESSAGING_DESTINATION_NAME = 'messaging.destination.name';
-const ATTR_MESSAGING_MESSAGE_ID = 'messaging.message.id';
-const ATTR_MESSAGING_OPERATION_NAME = 'messaging.operation.name';
+
const ATTR_MESSAGING_CONSUMER_GROUP_NAME = 'messaging.consumer.group.name';
const ATTR_MESSAGING_MESSAGE_DELIVERY_COUNT = 'messaging.message.delivery_count';
@@ -40,15 +43,15 @@ export function maybeEnrichQueueConsumerSpan(span: Span): void {
const consumerGroup = getHeader(headers, 'ce-vqsconsumergroup');
const deliveryCount = getHeader(headers, 'ce-vqsdeliverycount');
- span.setAttribute(ATTR_MESSAGING_SYSTEM, 'vercel.queue');
- span.setAttribute(ATTR_MESSAGING_OPERATION_NAME, 'process');
+ span.setAttribute(MESSAGING_SYSTEM, 'vercel.queue');
+ span.setAttribute(MESSAGING_OPERATION_NAME, 'process');
if (queueName) {
- span.setAttribute(ATTR_MESSAGING_DESTINATION_NAME, queueName);
+ span.setAttribute(MESSAGING_DESTINATION_NAME, queueName);
}
if (messageId) {
- span.setAttribute(ATTR_MESSAGING_MESSAGE_ID, messageId);
+ span.setAttribute(MESSAGING_MESSAGE_ID, messageId);
}
if (consumerGroup) {
@@ -101,9 +104,9 @@ export function maybeEnrichQueueProducerSpan(span: Span): void {
const topic = decodeURIComponent(topicMatch[1]!);
- span.setAttribute(ATTR_MESSAGING_SYSTEM, 'vercel.queue');
- span.setAttribute(ATTR_MESSAGING_DESTINATION_NAME, topic);
- span.setAttribute(ATTR_MESSAGING_OPERATION_NAME, 'send');
+ span.setAttribute(MESSAGING_SYSTEM, 'vercel.queue');
+ span.setAttribute(MESSAGING_DESTINATION_NAME, topic);
+ span.setAttribute(MESSAGING_OPERATION_NAME, 'send');
// Mark span so we can clean up marker on spanEnd
span.setAttribute(ATTR_SENTRY_QUEUE_ENRICHED, true);
diff --git a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts
index 0d8bdded0e0d..25ecbfdd3f80 100644
--- a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts
+++ b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
-import { SENTRY_OP, URL_FULL, URL_PATH } from '@sentry/conventions/attributes';
+import { SENTRY_LINK_TYPE, SENTRY_OP, URL_FULL, URL_PATH } from '@sentry/conventions/attributes';
import { PAGELOAD } from '@sentry/conventions/op';
import type { Client } from '@sentry/core';
import type * as SentryCore from '@sentry/core';
@@ -12,13 +12,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import '@sentry/core';
import '@sentry/react';
import '../../src/client/routing/appRouterRoutingInstrumentation';
+import '../../src/client/routing/appRouterPageloadInstrumentation';
import type * as AppRouterInstrumentation from '../../src/client/routing/appRouterRoutingInstrumentation';
+import type * as AppRouterPageload from '../../src/client/routing/appRouterPageloadInstrumentation';
import type * as RouteProvider from '../../src/client/routing/routeProvider';
import type { RouteManifest } from '../../src/config/manifest/types';
type Core = typeof SentryCore;
type React = typeof SentryReact;
type Instrumentation = typeof AppRouterInstrumentation;
+type PageloadInstrumentation = typeof AppRouterPageload;
type RouteProviderModule = typeof RouteProvider;
interface NextRouter {
@@ -67,11 +70,16 @@ async function setup(traceLifecycle: 'stream' | 'static'): Promise<{
router: NextRouter;
client: Client;
instrumentation: Instrumentation;
+ pageload: PageloadInstrumentation;
}> {
vi.resetModules();
const core: Core = await import('@sentry/core');
+ // The scopes live on a global carrier that `vi.resetModules()` keeps, so the active span of an
+ // earlier test would otherwise become the parent of the spans of this one.
+ core.getMainCarrier().__SENTRY__ = undefined;
const react: React = await import('@sentry/react');
const instrumentation: Instrumentation = await import('../../src/client/routing/appRouterRoutingInstrumentation');
+ const pageload: PageloadInstrumentation = await import('../../src/client/routing/appRouterPageloadInstrumentation');
const routeProvider: RouteProviderModule = await import('../../src/client/routing/routeProvider');
const client = new react.BrowserClient({
@@ -93,7 +101,7 @@ async function setup(traceLifecycle: 'stream' | 'static'): Promise<{
instrumentation.appRouterInstrumentNavigation(client);
await vi.waitFor(() => expect(router.back).not.toBe(originalBack));
- return { core, router, client, instrumentation };
+ return { core, router, client, instrumentation, pageload };
}
describe('appRouterInstrumentNavigation (router-patch mode)', () => {
@@ -269,11 +277,30 @@ describe('appRouterInstrumentNavigation with basePath', () => {
describe('appRouterInstrumentPageLoad', () => {
const originalReadyState = Object.getOwnPropertyDescriptor(Document.prototype, 'readyState');
+ const PRERENDER_TRACE_ID = '11111111111111111111111111111111';
+ const PRERENDER_SENTRY_TRACE = `${PRERENDER_TRACE_ID}-1111111111111111-1`;
+ const PRERENDER_LINK = {
+ trace_id: PRERENDER_TRACE_ID,
+ span_id: '1111111111111111',
+ sampled: true,
+ attributes: { [SENTRY_LINK_TYPE]: 'cache_origin' },
+ };
+
+ function setReadyState(value: DocumentReadyState): void {
+ Object.defineProperty(document, 'readyState', { value, configurable: true });
+ }
+
+ function appendMeta(parent: HTMLElement, name: string, content: string): void {
+ const meta = document.createElement('meta');
+ meta.setAttribute('name', name);
+ meta.setAttribute('content', content);
+ parent.appendChild(meta);
+ }
beforeEach(() => {
globalWithNext._sentryRouteManifest = JSON.stringify(manifest);
window.history.replaceState({}, '', '/navigation?from=document');
- Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true });
+ setReadyState('loading');
vi.spyOn(performance, 'getEntriesByType').mockReturnValue([]);
vi.stubEnv('_sentryCacheComponents', 'true');
});
@@ -281,6 +308,9 @@ describe('appRouterInstrumentPageLoad', () => {
afterEach(() => {
delete globalWithNext.next;
delete globalWithNext._sentryRouteManifest;
+ document.head.innerHTML = '';
+ document.body.innerHTML = '';
+ vi.useRealTimers();
// @ts-expect-error deleting the instance override restores the prototype getter
delete document.readyState;
if (originalReadyState) {
@@ -291,13 +321,13 @@ describe('appRouterInstrumentPageLoad', () => {
});
it('records the URL of the document when the pageload starts after a navigation changed it', async () => {
- const { core, instrumentation } = await setup('static');
+ const { core, pageload } = await setup('static');
- instrumentation.appRouterInstrumentPageLoad(core.getClient()!);
+ pageload.appRouterInstrumentPageLoad(core.getClient()!);
const spanBeforeSettling = core.getActiveSpan();
window.history.replaceState({}, '', '/navigation/42/router-back');
- instrumentation.settlePendingPageloadWait();
+ pageload.settlePendingPageloadWait();
const span = core.getActiveSpan();
expect(span).toBeDefined();
@@ -325,14 +355,82 @@ describe('appRouterInstrumentPageLoad', () => {
async ({ cacheComponents }) => {
vi.stubEnv('_sentryCacheComponents', cacheComponents);
window.history.replaceState({}, '', '/navigation?token=2c4f9e1b&from=document');
- const { core, instrumentation } = await setup('static');
+ const { core, pageload } = await setup('static');
- instrumentation.appRouterInstrumentPageLoad(core.getClient()!);
- instrumentation.settlePendingPageloadWait();
+ pageload.appRouterInstrumentPageLoad(core.getClient()!);
+ pageload.settlePendingPageloadWait();
const filteredUrl = 'http://localhost:3000/navigation?token=[Filtered]&from=document';
expect(core.spanToJSON(core.getActiveSpan()!).attributes?.[URL_FULL]).toBe(filteredUrl);
expect(core.getCurrentScope().getScopeData().sdkProcessingMetadata.normalizedRequest?.url).toBe(filteredUrl);
},
);
+
+ it('starts a new trace for a static page and links the prerender as cache origin', async () => {
+ const { core, pageload } = await setup('static');
+ setReadyState('complete');
+ appendMeta(document.head, 'sentry-cache-origin', PRERENDER_SENTRY_TRACE);
+
+ pageload.appRouterInstrumentPageLoad(core.getClient()!);
+
+ const spanJson = core.spanToJSON(core.getActiveSpan()!);
+ expect(spanJson.attributes?.[SENTRY_OP]).toBe(PAGELOAD);
+ expect(spanJson.attributes?.['page.server_rendering.type']).toBe('static');
+ expect(spanJson.trace_id).not.toBe(PRERENDER_TRACE_ID);
+ expect(spanJson.links).toEqual([PRERENDER_LINK]);
+ });
+
+ // A shell that was prerendered at runtime keeps its tag, and the part resumed for this request
+ // brings its own `sentry-trace` tag.
+ it('continues the server trace of a resumed cached shell and links the prerender as cache origin', async () => {
+ const { core, pageload } = await setup('static');
+ setReadyState('complete');
+ appendMeta(document.head, 'sentry-cache-origin', PRERENDER_SENTRY_TRACE);
+ appendMeta(document.body, 'sentry-trace', '22222222222222222222222222222222-2222222222222222-1');
+
+ pageload.appRouterInstrumentPageLoad(core.getClient()!);
+
+ const spanJson = core.spanToJSON(core.getActiveSpan()!);
+ expect(spanJson.trace_id).toBe('22222222222222222222222222222222');
+ expect(spanJson.parent_span_id).toBe('2222222222222222');
+ expect(spanJson.attributes?.['page.server_rendering.type']).toBe('partially_static');
+ expect(spanJson.links).toEqual([PRERENDER_LINK]);
+ });
+
+ it('links the prerender without a type when a navigation starts the pageload before the resumed part', async () => {
+ const { core, pageload } = await setup('static');
+ appendMeta(document.head, 'sentry-cache-origin', PRERENDER_SENTRY_TRACE);
+
+ pageload.appRouterInstrumentPageLoad(core.getClient()!);
+ pageload.settlePendingPageloadWait();
+
+ const spanJson = core.spanToJSON(core.getActiveSpan()!);
+ expect(spanJson.attributes?.[SENTRY_OP]).toBe(PAGELOAD);
+ expect(spanJson.attributes).not.toHaveProperty('page.server_rendering.type');
+ expect(spanJson.links).toEqual([PRERENDER_LINK]);
+ });
+
+ it('links the server request when its trace meta tag arrives after the wait gave up', async () => {
+ const { core, pageload } = await setup('static');
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
+
+ pageload.appRouterInstrumentPageLoad(core.getClient()!);
+ vi.advanceTimersByTime(10_000);
+ const span = core.getActiveSpan()!;
+ appendMeta(document.body, 'sentry-trace', '22222222222222222222222222222222-2222222222222222-1');
+ // MutationObserver callbacks run as microtasks.
+ await Promise.resolve();
+
+ const spanJson = core.spanToJSON(span);
+ expect(spanJson.attributes?.[SENTRY_OP]).toBe(PAGELOAD);
+ expect(spanJson.trace_id).not.toBe('22222222222222222222222222222222');
+ expect(spanJson.links).toEqual([
+ {
+ trace_id: '22222222222222222222222222222222',
+ span_id: '2222222222222222',
+ sampled: true,
+ attributes: { [SENTRY_LINK_TYPE]: 'server_request' },
+ },
+ ]);
+ });
});
diff --git a/packages/nextjs/test/client/traceMetaTags.test.ts b/packages/nextjs/test/client/traceMetaTags.test.ts
index 4b9fde700e92..695ad7c558bb 100644
--- a/packages/nextjs/test/client/traceMetaTags.test.ts
+++ b/packages/nextjs/test/client/traceMetaTags.test.ts
@@ -1,12 +1,14 @@
// @vitest-environment jsdom
-import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes';
-import type { Span } from '@sentry/core';
+import { SENTRY_LINK_TYPE, SENTRY_OP } from '@sentry/conventions/attributes';
+import { PAGELOAD } from '@sentry/conventions/op';
+import type { Span, StartSpanOptions } from '@sentry/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
addTraceMetaTagLink,
canWaitForTraceMetaTag,
readTraceMetaTags,
waitForTraceMetaTag,
+ withCacheOrigin,
} from '../../src/client/routing/traceMetaTags';
const HEAD_TRACE = '11111111111111111111111111111111-1111111111111111-1';
@@ -219,3 +221,63 @@ describe('addTraceMetaTagLink', () => {
expect(span.addLink).not.toHaveBeenCalled();
});
});
+
+describe('withCacheOrigin', () => {
+ const PAGELOAD_OPTIONS: StartSpanOptions = { name: '/products/:id', attributes: { [SENTRY_OP]: PAGELOAD } };
+ const PRERENDER_LINK = {
+ context: { traceId: '11111111111111111111111111111111', spanId: '1111111111111111', traceFlags: 1 },
+ attributes: { [SENTRY_LINK_TYPE]: 'cache_origin' },
+ };
+
+ it('returns the options unchanged for a document without a sentry-cache-origin tag', () => {
+ appendMeta(document.head, 'sentry-trace', HEAD_TRACE);
+
+ expect(withCacheOrigin(PAGELOAD_OPTIONS, { sentryTrace: HEAD_TRACE, baggage: undefined })).toBe(PAGELOAD_OPTIONS);
+ });
+
+ it('marks a fully reused page as static and links a sampled render as cache origin', () => {
+ appendMeta(document.head, 'sentry-cache-origin', HEAD_TRACE);
+
+ expect(withCacheOrigin(PAGELOAD_OPTIONS)).toEqual({
+ name: '/products/:id',
+ attributes: { [SENTRY_OP]: PAGELOAD, 'page.server_rendering.type': 'static' },
+ links: [PRERENDER_LINK],
+ });
+ });
+
+ it('marks a page as partially static when the pageload continues the trace of this request', () => {
+ appendMeta(document.head, 'sentry-cache-origin', HEAD_TRACE);
+
+ expect(withCacheOrigin(PAGELOAD_OPTIONS, { sentryTrace: BODY_TRACE, baggage: undefined })).toEqual({
+ name: '/products/:id',
+ attributes: { [SENTRY_OP]: PAGELOAD, 'page.server_rendering.type': 'partially_static' },
+ links: [PRERENDER_LINK],
+ });
+ });
+
+ // A navigation or the wait cap can start the pageload before the resumed part arrives.
+ it('leaves the type unset while a resumed part can still bring the trace of this request', () => {
+ appendMeta(document.head, 'sentry-cache-origin', HEAD_TRACE);
+ setReadyState('loading');
+ vi.spyOn(performance, 'getEntriesByType').mockReturnValue([]);
+
+ expect(withCacheOrigin(PAGELOAD_OPTIONS)).toEqual({
+ name: '/products/:id',
+ attributes: { [SENTRY_OP]: PAGELOAD },
+ links: [PRERENDER_LINK],
+ });
+ });
+
+ it.each([
+ { tag: 'holds an unsampled trace', content: '11111111111111111111111111111111-1111111111111111-0' },
+ { tag: 'holds a trace without a sampling decision', content: '11111111111111111111111111111111-1111111111111111' },
+ { tag: 'holds no sentry-trace value', content: 'prerendered' },
+ ])('marks the page without a link when the tag $tag', ({ content }) => {
+ appendMeta(document.head, 'sentry-cache-origin', content);
+
+ expect(withCacheOrigin(PAGELOAD_OPTIONS)).toEqual({
+ name: '/products/:id',
+ attributes: { [SENTRY_OP]: PAGELOAD, 'page.server_rendering.type': 'static' },
+ });
+ });
+});
diff --git a/packages/nextjs/test/cloudflare/prepareSafeIdGeneratorContext.test.ts b/packages/nextjs/test/cloudflare/prepareSafeIdGeneratorContext.test.ts
new file mode 100644
index 000000000000..9838296d6df6
--- /dev/null
+++ b/packages/nextjs/test/cloudflare/prepareSafeIdGeneratorContext.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from 'vitest';
+import '../../src/cloudflare';
+
+const RUNNER_KEY = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__');
+
+describe('@sentry/nextjs/cloudflare', () => {
+ it('registers the safe random ID runner when the module loads', () => {
+ const runner = (globalThis as Record)[RUNNER_KEY] as ((callback: () => T) => T) | undefined;
+ expect(runner).toBeTypeOf('function');
+ expect(runner!(() => 'ok')).toBe('ok');
+ });
+});
diff --git a/packages/nextjs/test/cloudflare/withSentry.test.ts b/packages/nextjs/test/cloudflare/withSentry.test.ts
index d24bb75f3f61..46bc97e520be 100644
--- a/packages/nextjs/test/cloudflare/withSentry.test.ts
+++ b/packages/nextjs/test/cloudflare/withSentry.test.ts
@@ -287,7 +287,7 @@ describe('withSentry', () => {
props: {},
} as never);
- expect(carrier).toEqual([]);
+ expect(carrier).toEqual([{ key: 'sentry-cache-origin', value: expect.stringMatching(/-1$/) }]);
expect(requestCarrier['sentry-trace']).toMatch(/^[0-9a-f]{32}-[0-9a-f]{16}-1$/);
});
diff --git a/packages/nextjs/test/common/utils/forkIsolationScopeForRootSpan.test.ts b/packages/nextjs/test/common/utils/forkIsolationScopeForRootSpan.test.ts
new file mode 100644
index 000000000000..92a7eb3341e1
--- /dev/null
+++ b/packages/nextjs/test/common/utils/forkIsolationScopeForRootSpan.test.ts
@@ -0,0 +1,42 @@
+import { getCapturedScopesOnSpan, getIsolationScope, SentrySpan, startSpan, withActiveSpan } from '@sentry/core';
+import { describe, expect, it } from 'vitest';
+import { ATTR_NEXT_SPAN_TYPE } from '../../../src/common/nextSpanAttributes';
+import { maybeForkIsolationScopeForRootSpan } from '../../../src/common/utils/forkIsolationScopeForRootSpan';
+
+describe('maybeForkIsolationScopeForRootSpan', () => {
+ it.each(['BaseServer.handleRequest', 'Middleware.execute', 'Next.request'])(
+ 'forks the isolation scope for %s root spans',
+ spanType => {
+ const span = new SentrySpan({ name: 'GET' });
+ const isolationScope = getIsolationScope();
+
+ maybeForkIsolationScopeForRootSpan(span, { [ATTR_NEXT_SPAN_TYPE]: spanType });
+
+ const forkedIsolationScope = getCapturedScopesOnSpan(span).isolationScope;
+ expect(forkedIsolationScope).toBeDefined();
+ expect(forkedIsolationScope).not.toBe(isolationScope);
+ },
+ );
+
+ it('does not fork the isolation scope for other span types', () => {
+ const span = new SentrySpan({ name: 'render' });
+
+ maybeForkIsolationScopeForRootSpan(span, { [ATTR_NEXT_SPAN_TYPE]: 'Render.getServerSideProps' });
+
+ expect(getCapturedScopesOnSpan(span).isolationScope).toBeUndefined();
+ });
+
+ it('does not fork the isolation scope for non-root spans', () => {
+ const rootSpan = new SentrySpan({ name: 'GET' });
+
+ withActiveSpan(rootSpan, () => {
+ startSpan({ name: 'middleware GET' }, childSpan => {
+ const isolationScopeBefore = getCapturedScopesOnSpan(childSpan).isolationScope;
+
+ maybeForkIsolationScopeForRootSpan(childSpan, { [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute' });
+
+ expect(getCapturedScopesOnSpan(childSpan).isolationScope).toBe(isolationScopeBefore);
+ });
+ });
+ });
+});
diff --git a/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts b/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts
index b7a81d3fc1c0..634218b4cdaa 100644
--- a/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts
+++ b/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts
@@ -656,6 +656,20 @@ describe('stripSourceMappingURLComments', () => {
expect(content).not.toContain('sourceMappingURL');
});
+ it.each(['\n', '\r\n'])('preserves a trailing debugId comment with %j line endings', async newline => {
+ const filePath = path.join(tmpDir, 'chunks', 'turbopack-worker.js');
+ const debugIdComment = '//# debugId=12345678-1234-4234-8234-123456789abc';
+ await fs.promises.writeFile(
+ filePath,
+ `self.onmessage = () => {};${newline}//# sourceMappingURL=worker.js.map${newline}${debugIdComment}${newline}`,
+ );
+
+ await stripSourceMappingURLComments(tmpDir);
+
+ const content = await fs.promises.readFile(filePath, 'utf-8');
+ expect(content).toBe(`self.onmessage = () => {};${newline}${debugIdComment}${newline}`);
+ });
+
it('strips sourceMappingURL comment from MJS files', async () => {
const filePath = path.join(tmpDir, 'chunks', 'module.mjs');
await fs.promises.writeFile(filePath, 'export default 42;\n//# sourceMappingURL=module.mjs.map');
diff --git a/packages/nextjs/test/config/maybeSetClientTraceMetadataOption.test.ts b/packages/nextjs/test/config/maybeSetClientTraceMetadataOption.test.ts
index 6de4a3a697fd..3dd60f97f848 100644
--- a/packages/nextjs/test/config/maybeSetClientTraceMetadataOption.test.ts
+++ b/packages/nextjs/test/config/maybeSetClientTraceMetadataOption.test.ts
@@ -3,22 +3,27 @@ import type { NextConfigObject } from '../../src/config/types';
import { maybeSetClientTraceMetadataOption } from '../../src/config/withSentryConfig/getFinalConfigObjectUtils';
describe('maybeSetClientTraceMetadataOption', () => {
- it('adds sentry-trace and baggage to clientTraceMetadata on supported Next.js versions', () => {
+ it('adds baggage, sentry-trace, and sentry-cache-origin to clientTraceMetadata on supported Next.js versions', () => {
const config: NextConfigObject = {};
maybeSetClientTraceMetadataOption(config, '15.0.0');
- expect(config.experimental?.clientTraceMetadata).toEqual(['baggage', 'sentry-trace']);
+ expect(config.experimental?.clientTraceMetadata).toEqual(['baggage', 'sentry-trace', 'sentry-cache-origin']);
});
it('preserves user-provided clientTraceMetadata entries', () => {
const config: NextConfigObject = { experimental: { clientTraceMetadata: ['my-custom-key'] } };
maybeSetClientTraceMetadataOption(config, '15.0.0');
- expect(config.experimental?.clientTraceMetadata).toEqual(['baggage', 'sentry-trace', 'my-custom-key']);
+ expect(config.experimental?.clientTraceMetadata).toEqual([
+ 'baggage',
+ 'sentry-trace',
+ 'sentry-cache-origin',
+ 'my-custom-key',
+ ]);
});
it('enables trace meta tags when Cache Components is enabled', () => {
// The server withholds the tags while it prerenders, see `nextSentryPropagator.ts`.
const config: NextConfigObject = { cacheComponents: true };
maybeSetClientTraceMetadataOption(config, '16.0.0');
- expect(config.experimental?.clientTraceMetadata).toEqual(['baggage', 'sentry-trace']);
+ expect(config.experimental?.clientTraceMetadata).toEqual(['baggage', 'sentry-trace', 'sentry-cache-origin']);
});
});
diff --git a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts
index 3670ff6244e7..39aa27f8760a 100644
--- a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts
+++ b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts
@@ -990,20 +990,40 @@ describe('moduleMetadataInjection with applicationKey', () => {
expect(rule.loaders[0]!.options.applicationKey).toBe('my-top-level-key');
});
- it('should only tag browser modules and exclude Next.js polyfills, not all foreign modules', () => {
- const userNextConfig: NextConfigObject = {};
+ it.each([undefined, false])(
+ 'includes dependencies when applicationKeyExcludeDependencies is %s',
+ applicationKeyExcludeDependencies => {
+ const userNextConfig: NextConfigObject = {};
+
+ const result = constructTurbopackConfig({
+ userNextConfig,
+ userSentryOptions: { applicationKey: 'my-app', applicationKeyExcludeDependencies },
+ nextJsVersion: '16.0.0',
+ });
+
+ const rule = result.rules!['*.{ts,tsx,js,jsx,mjs,cjs}'] as { condition?: unknown; loaders: unknown[] };
+ expect(rule.condition).toEqual({ all: ['browser', { not: { path: /next\/dist\/build\/polyfills/ } }] });
+ },
+ );
+ it('excludes dependencies from browser metadata injection when opted in', () => {
const result = constructTurbopackConfig({
- userNextConfig,
- userSentryOptions: { applicationKey: 'my-app' },
+ userNextConfig: {},
+ userSentryOptions: { applicationKey: 'my-app', applicationKeyExcludeDependencies: true },
nextJsVersion: '16.0.0',
});
- const rule = result.rules!['*.{ts,tsx,js,jsx,mjs,cjs}'] as { condition?: unknown; loaders: unknown[] };
- // Unlike component annotation (which uses { not: 'foreign' }), metadata injection
- // must cover node_modules to match the webpack plugin's BannerPlugin behavior.
- // Only Next.js build polyfills are excluded because they have non-standard syntax.
- expect(rule.condition).toEqual({ all: ['browser', { not: { path: /next\/dist\/build\/polyfills/ } }] });
+ expect(result.rules!['*.{ts,tsx,js,jsx,mjs,cjs}']).toEqual({
+ condition: {
+ all: ['browser', { not: { path: /next\/dist\/build\/polyfills/ } }, { not: 'foreign' }],
+ },
+ loaders: [
+ {
+ loader: '/mocked/path/to/moduleMetadataInjectionLoader.js',
+ options: { applicationKey: 'my-app' },
+ },
+ ],
+ });
});
it('should NOT add metadata loader rule when Next.js < 16', () => {
@@ -1018,17 +1038,20 @@ describe('moduleMetadataInjection with applicationKey', () => {
expect(result.rules!['*.{ts,tsx,js,jsx,mjs,cjs}']).toBeUndefined();
});
- it('should NOT add metadata loader rule when applicationKey is not set', () => {
- const userNextConfig: NextConfigObject = {};
+ it.each([undefined, true])(
+ 'does not inject metadata without applicationKey when exclusion is %s',
+ applicationKeyExcludeDependencies => {
+ const userNextConfig: NextConfigObject = {};
- const result = constructTurbopackConfig({
- userNextConfig,
- userSentryOptions: {},
- nextJsVersion: '16.0.0',
- });
+ const result = constructTurbopackConfig({
+ userNextConfig,
+ userSentryOptions: { applicationKeyExcludeDependencies },
+ nextJsVersion: '16.0.0',
+ });
- expect(result.rules!['*.{ts,tsx,js,jsx,mjs,cjs}']).toBeUndefined();
- });
+ expect(result.rules!['*.{ts,tsx,js,jsx,mjs,cjs}']).toBeUndefined();
+ },
+ );
it('should NOT add metadata loader rule when nextJsVersion is undefined', () => {
const userNextConfig: NextConfigObject = {};
diff --git a/packages/nextjs/test/config/withSentryConfig.test.ts b/packages/nextjs/test/config/withSentryConfig.test.ts
index cc749732eb35..9cec2cc71410 100644
--- a/packages/nextjs/test/config/withSentryConfig.test.ts
+++ b/packages/nextjs/test/config/withSentryConfig.test.ts
@@ -1302,6 +1302,41 @@ describe('withSentryConfig', () => {
});
});
+ describe('applicationKeyExcludeDependencies warnings', () => {
+ beforeEach(() => {
+ vi.spyOn(util, 'getNextjsVersion').mockReturnValue('16.0.0');
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('warns without applicationKey', () => {
+ vi.spyOn(util, 'detectActiveBundler').mockReturnValue('turbopack');
+ const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+
+ materializeFinalNextConfig(exportedNextConfig, undefined, { applicationKeyExcludeDependencies: true });
+
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ '[@sentry/nextjs] `applicationKeyExcludeDependencies` requires `applicationKey` and has no effect without it.',
+ );
+ });
+
+ it('warns on webpack when applicationKey is set', () => {
+ vi.spyOn(util, 'detectActiveBundler').mockReturnValue('webpack');
+ const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+
+ materializeFinalNextConfig(exportedNextConfig, undefined, {
+ applicationKey: 'my-app',
+ applicationKeyExcludeDependencies: true,
+ });
+
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ '[@sentry/nextjs] `applicationKeyExcludeDependencies` only applies to Turbopack builds and has no effect on webpack builds.',
+ );
+ });
+ });
+
describe('turbopack version compatibility warnings', () => {
const originalTurbopack = process.env.TURBOPACK;
const originalNodeEnv = process.env.NODE_ENV;
diff --git a/packages/nextjs/test/edge/beforeSampling.test.ts b/packages/nextjs/test/edge/beforeSampling.test.ts
index 1d2dc66bcf1c..6bc58b406d83 100644
--- a/packages/nextjs/test/edge/beforeSampling.test.ts
+++ b/packages/nextjs/test/edge/beforeSampling.test.ts
@@ -83,6 +83,21 @@ describe('edge beforeSampling handler', () => {
});
});
+ it('seeds normalizedRequest for Next.request root spans', () => {
+ beforeSamplingHandler!({
+ spanAttributes: {
+ [ATTR_NEXT_SPAN_TYPE]: 'Next.request',
+ 'http.method': 'GET',
+ 'http.target': '/behind-proxy',
+ },
+ });
+
+ expect(getNormalizedRequest()).toEqual({
+ method: 'GET',
+ url: '/behind-proxy',
+ });
+ });
+
it('overwrites stale normalizedRequest from a previous invocation', () => {
getIsolationScope().setSDKProcessingMetadata({
normalizedRequest: { method: 'GET', url: '/previous-request' },
diff --git a/packages/nextjs/test/performance/nextRoutingInstrumentation.test.ts b/packages/nextjs/test/performance/nextRoutingInstrumentation.test.ts
index 4b6b117e2117..bc107683f4ba 100644
--- a/packages/nextjs/test/performance/nextRoutingInstrumentation.test.ts
+++ b/packages/nextjs/test/performance/nextRoutingInstrumentation.test.ts
@@ -2,10 +2,8 @@ import type { Client } from '@sentry/core';
import { WINDOW } from '@sentry/react';
import { JSDOM } from 'jsdom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import {
- appRouterInstrumentNavigation,
- appRouterInstrumentPageLoad,
-} from '../../src/client/routing/appRouterRoutingInstrumentation';
+import { appRouterInstrumentNavigation } from '../../src/client/routing/appRouterRoutingInstrumentation';
+import { appRouterInstrumentPageLoad } from '../../src/client/routing/appRouterPageloadInstrumentation';
import {
nextRouterInstrumentNavigation,
nextRouterInstrumentPageLoad,
@@ -13,9 +11,12 @@ import {
import { pagesRouterInstrumentNavigation } from '../../src/client/routing/pagesRouterNavigationInstrumentation';
import { pagesRouterInstrumentPageLoad } from '../../src/client/routing/pagesRouterRoutingInstrumentation';
+vi.mock('../../src/client/routing/appRouterPageloadInstrumentation', () => ({
+ appRouterInstrumentPageLoad: vi.fn(),
+}));
+
vi.mock('../../src/client/routing/appRouterRoutingInstrumentation', () => ({
appRouterInstrumentNavigation: vi.fn(),
- appRouterInstrumentPageLoad: vi.fn(),
}));
vi.mock('../../src/client/routing/pagesRouterNavigationInstrumentation', () => ({
pagesRouterInstrumentNavigation: vi.fn(),
diff --git a/packages/nextjs/test/server/nextSentryPropagator.test.ts b/packages/nextjs/test/server/nextSentryPropagator.test.ts
index 03c2e40e4e2d..d437b1b8a164 100644
--- a/packages/nextjs/test/server/nextSentryPropagator.test.ts
+++ b/packages/nextjs/test/server/nextSentryPropagator.test.ts
@@ -82,32 +82,34 @@ describe('NextSentryPropagator', () => {
});
});
- it('hands out nothing while a route is prerendered', () => {
+ it('hands out only the trace of the prerender as cache origin while a route is prerendered', () => {
trace.getTracer('next.js').startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, span => {
expect(isPrerenderContext(context.active())).toBe(true);
- expect(inject()).toEqual({});
+ expect(inject()).toEqual({
+ 'sentry-cache-origin': `${span.spanContext().traceId}-${span.spanContext().spanId}-1`,
+ });
span.end();
});
});
it('keeps propagating to outgoing requests while a route is prerendered', () => {
trace.getTracer('next.js').startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, span => {
- expect(inject()).toEqual({});
+ expect(inject()).toEqual({ 'sentry-cache-origin': expect.stringMatching(/-1$/) });
expect(injectHeaders()['sentry-trace']).toBe(`${span.spanContext().traceId}-${span.spanContext().spanId}-1`);
span.end();
});
});
- it('hands out nothing inside a span nested in a prerender', () => {
+ it('hands out only the cache origin inside a span nested in a prerender', () => {
trace.getTracer('next.js').startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, span => {
startSpan({ name: 'server component' }, () => {
- expect(inject()).toEqual({});
+ expect(Object.keys(inject())).toEqual(['sentry-cache-origin']);
});
span.end();
});
});
- it('hands out nothing while a route is prerendered in an unsampled trace', () => {
+ it('hands out only the cache origin while a route is prerendered in an unsampled trace', () => {
setup(0);
trace.getTracer('next.js').startActiveSpan('GET /[id]', span => {
@@ -118,7 +120,7 @@ describe('NextSentryPropagator', () => {
.getTracer('next.js')
.startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, prerenderSpan => {
expect(prerenderSpan.isRecording()).toBe(false);
- expect(inject()).toEqual({});
+ expect(inject()).toEqual({ 'sentry-cache-origin': expect.stringMatching(/-0$/) });
prerenderSpan.end();
});
span.end();
@@ -129,7 +131,7 @@ describe('NextSentryPropagator', () => {
const span = trace.getTracer('next.js').startSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS);
context.with(trace.setSpan(context.active(), span), () => {
expect(isPrerenderContext(context.active())).toBe(true);
- expect(inject()).toEqual({});
+ expect(Object.keys(inject())).toEqual(['sentry-cache-origin']);
});
span.end();
@@ -145,7 +147,7 @@ describe('NextSentryPropagator', () => {
.getTracer('next.js')
.startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, context.active(), span => {
expect(isPrerenderContext(context.active())).toBe(true);
- expect(inject()).toEqual({});
+ expect(Object.keys(inject())).toEqual(['sentry-cache-origin']);
span.end();
});
});
diff --git a/packages/nextjs/test/server/prepareSafeIdGeneratorContext.test.ts b/packages/nextjs/test/server/prepareSafeIdGeneratorContext.test.ts
new file mode 100644
index 000000000000..9fce481d75e8
--- /dev/null
+++ b/packages/nextjs/test/server/prepareSafeIdGeneratorContext.test.ts
@@ -0,0 +1,147 @@
+import { AsyncLocalStorage } from 'node:async_hooks';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { prepareSafeIdGeneratorContext } from '../../src/server/prepareSafeIdGeneratorContext';
+
+const RUNNER_KEY = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__');
+const NEXT_WORK_UNIT_STORAGE_KEY = Symbol.for('@next/work-unit-async-storage@16.4.0');
+
+type Runner = (callback: () => T) => T;
+
+function getRunner(): Runner {
+ const runner = (globalThis as Record)[RUNNER_KEY];
+ if (typeof runner !== 'function') {
+ throw new Error('runner not registered');
+ }
+ return runner as Runner;
+}
+
+function registerNextWorkUnitStorage(storage: AsyncLocalStorage): void {
+ (globalThis as Record)[NEXT_WORK_UNIT_STORAGE_KEY] = storage;
+}
+
+/**
+ * Mimics workerd with `bind_asynclocalstorage_snapshot_to_request`: a snapshot taken during one request throws when
+ * it is called during another one.
+ */
+function bindSnapshotsToRequest(): { enterRequest: (id: number) => void } {
+ const originalSnapshot = AsyncLocalStorage.snapshot.bind(AsyncLocalStorage);
+ let currentRequest = 0;
+
+ vi.spyOn(AsyncLocalStorage, 'snapshot').mockImplementation(() => {
+ const boundRequest = currentRequest;
+ const snapshot = originalSnapshot();
+ return ((callback: () => unknown) => {
+ if (currentRequest !== boundRequest) {
+ throw new Error(
+ 'Cannot call this AsyncLocalStorage bound function outside of the request in which it was created.',
+ );
+ }
+ return snapshot(callback);
+ }) as ReturnType;
+ });
+
+ return {
+ enterRequest: id => {
+ currentRequest = id;
+ },
+ };
+}
+
+describe('prepareSafeIdGeneratorContext', () => {
+ let workUnitStorage: AsyncLocalStorage<{ type: string }>;
+ const prerenderStore = { type: 'prerender' };
+
+ beforeEach(() => {
+ workUnitStorage = new AsyncLocalStorage();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ Reflect.deleteProperty(globalThis, RUNNER_KEY);
+ Reflect.deleteProperty(globalThis, NEXT_WORK_UNIT_STORAGE_KEY);
+ });
+
+ it('runs the callback outside of the Next.js work unit store using a snapshot', () => {
+ prepareSafeIdGeneratorContext();
+
+ const storeSeen = workUnitStorage.run(prerenderStore, () => getRunner()(() => workUnitStorage.getStore()));
+
+ expect(storeSeen).toBeUndefined();
+ });
+
+ it('exits the work unit storage that Next.js registers on globalThis', () => {
+ registerNextWorkUnitStorage(workUnitStorage);
+ prepareSafeIdGeneratorContext();
+
+ const storeSeen = workUnitStorage.run(prerenderStore, () => getRunner()(() => workUnitStorage.getStore()));
+
+ expect(storeSeen).toBeUndefined();
+ });
+
+ it('keeps unrelated stores intact when exiting the work unit storage', () => {
+ registerNextWorkUnitStorage(workUnitStorage);
+ prepareSafeIdGeneratorContext();
+ const otherStorage = new AsyncLocalStorage();
+
+ const storeSeen = otherStorage.run('request-data', () =>
+ workUnitStorage.run(prerenderStore, () => getRunner()(() => otherStorage.getStore())),
+ );
+
+ expect(storeSeen).toBe('request-data');
+ });
+
+ it('does not run the callback inside a prerender of another request when snapshots are request-bound', () => {
+ const { enterRequest } = bindSnapshotsToRequest();
+ registerNextWorkUnitStorage(workUnitStorage);
+
+ enterRequest(1);
+ prepareSafeIdGeneratorContext();
+ const runner = getRunner();
+
+ enterRequest(2);
+ const storeSeen = workUnitStorage.run(prerenderStore, () => runner(() => workUnitStorage.getStore()));
+
+ expect(storeSeen).toBeUndefined();
+ });
+
+ it('finds the work unit storage that Next.js registers after the runner was prepared', () => {
+ const { enterRequest } = bindSnapshotsToRequest();
+
+ enterRequest(1);
+ prepareSafeIdGeneratorContext();
+ const runner = getRunner();
+ registerNextWorkUnitStorage(workUnitStorage);
+
+ enterRequest(2);
+ const storeSeen = workUnitStorage.run(prerenderStore, () => runner(() => workUnitStorage.getStore()));
+
+ expect(storeSeen).toBeUndefined();
+ });
+
+ it('ignores a registered storage whose exit throws, like the Next.js FakeAsyncLocalStorage', () => {
+ const fakeStorage = {
+ getStore: () => undefined,
+ exit: () => {
+ throw new Error('Invariant: AsyncLocalStorage accessed in runtime where it is not available');
+ },
+ };
+ (globalThis as Record)[NEXT_WORK_UNIT_STORAGE_KEY] = fakeStorage;
+ prepareSafeIdGeneratorContext();
+
+ const storeSeen = workUnitStorage.run(prerenderStore, () => getRunner()(() => workUnitStorage.getStore()));
+
+ expect(storeSeen).toBeUndefined();
+ });
+
+ it('falls back to a fresh snapshot when Next.js does not register its storage', () => {
+ const { enterRequest } = bindSnapshotsToRequest();
+
+ enterRequest(1);
+ prepareSafeIdGeneratorContext();
+ const runner = getRunner();
+
+ enterRequest(2);
+ expect(runner(() => 'value')).toBe('value');
+ expect(AsyncLocalStorage.snapshot).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts
index 5fa91525bd68..e5b506d02c81 100644
--- a/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts
+++ b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts
@@ -125,6 +125,20 @@ describe('dropMiddlewareTunnelRequests', () => {
});
});
+ describe('Next.request spans', () => {
+ it('marks the proxy root span for dropping when http.target matches tunnel path', () => {
+ globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring';
+ const span = createMockSpan();
+
+ dropMiddlewareTunnelRequests(span as any, {
+ 'next.span_type': 'Next.request',
+ 'http.target': '/monitoring',
+ });
+
+ expect(span.setAttribute).toHaveBeenCalledWith(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true);
+ });
+ });
+
describe('unrelated spans', () => {
it('does not process spans without matching span type or origin', () => {
globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring';
@@ -211,6 +225,26 @@ describe('getTunnelRouteIgnoreSpans', () => {
},
);
+ it.each([
+ ['rewrite', '/monitoring'],
+ ['rewrite', '/monitoring/'],
+ ['handler', '/monitoring'],
+ ] as const)('ignores a Next.request proxy root span in %s mode for %s', (mode, target) => {
+ globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring';
+ globalWithInjectedValues._sentryTunnelRouteMode = mode;
+
+ expect(isIgnored(nextRequestSpanAttributes('Next.request', target))).toBe(true);
+ });
+
+ it.each(['/monitoring/dashboard', '/monitoring-dashboard', '/api/monitoring'])(
+ 'does not ignore a Next.request proxy root span for %s',
+ target => {
+ globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring';
+
+ expect(isIgnored(nextRequestSpanAttributes('Next.request', target))).toBe(false);
+ },
+ );
+
it('does not ignore a request span for a sub-path in handler mode', () => {
globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring';
globalWithInjectedValues._sentryTunnelRouteMode = 'handler';
diff --git a/packages/nitro/package.json b/packages/nitro/package.json
index 02956fcac964..82ecbf806541 100644
--- a/packages/nitro/package.json
+++ b/packages/nitro/package.json
@@ -36,7 +36,7 @@
},
"dependencies": {
"@sentry/bundler-plugins": "11.10.0",
- "@sentry/conventions": "^0.29.0",
+ "@sentry/conventions": "^0.30.0",
"@sentry/core": "11.10.0",
"@sentry/node": "11.10.0",
"@sentry/server-runtime-injection": "11.10.0",
diff --git a/packages/nitro/rollup.npm.config.mjs b/packages/nitro/rollup.npm.config.mjs
index aebbc9aff288..51990c5771a6 100644
--- a/packages/nitro/rollup.npm.config.mjs
+++ b/packages/nitro/rollup.npm.config.mjs
@@ -5,7 +5,7 @@ export default [
makeBaseNPMConfig({
entrypoints: ['src/index.ts', 'src/runtime/plugins/server.ts'],
packageSpecificConfig: {
- external: [/^nitro/, /^srvx/, /^@sentry\/opentelemetry/],
+ external: [/^nitro/, /^#nitro\//, /^srvx/, /^@sentry\/opentelemetry/],
},
}),
{ emitCjs: false },
diff --git a/packages/nitro/src/module.ts b/packages/nitro/src/module.ts
index f8b6174a7874..16ef233a809d 100644
--- a/packages/nitro/src/module.ts
+++ b/packages/nitro/src/module.ts
@@ -17,7 +17,9 @@ export function createNitroModule(sentryOptions?: SentryNitroOptions, sentryEnab
// In dev, Vite's server takes each request in this process before Nitro handles it, and its `http.server` span
// comes from the SDK `--import` set up here. The runtime plugin only reaches the client where Nitro runs.
if (nitro.options.dev) {
- setManagedTunnelDefaults({ basePath: nitro.options.baseURL });
+ // `sentryTunnelBasePath` is set by `@sentry/solidstart`, whose app is served under Vite's `base`.
+ const tunnelBasePath = nitro.options.runtimeConfig.sentryTunnelBasePath as string | undefined;
+ setManagedTunnelDefaults({ basePath: tunnelBasePath || nitro.options.baseURL });
}
},
};
diff --git a/packages/nitro/src/runtime/hooks/captureErrorHook.ts b/packages/nitro/src/runtime/hooks/captureErrorHook.ts
index d07bad842ea0..164da1936401 100644
--- a/packages/nitro/src/runtime/hooks/captureErrorHook.ts
+++ b/packages/nitro/src/runtime/hooks/captureErrorHook.ts
@@ -1,4 +1,4 @@
-import { captureException, getClient, parseUrl } from '@sentry/core';
+import { captureException, getClient, isAlreadyCaptured, parseUrl } from '@sentry/core';
import { flushIfServerless } from '@sentry/core/server';
import { HTTPError } from 'nitro/h3';
import type { CapturedErrorContext } from 'nitro/types';
@@ -46,6 +46,12 @@ export async function captureErrorHook(error: Error, errorContext: CapturedError
return;
}
+ // Nitro wraps errors thrown in handlers in an `HTTPError`, so an error the SDK already captured (for example in a
+ // task) would be reported a second time through the wrapper.
+ if (HTTPError.isError(error) && isAlreadyCaptured(error.cause)) {
+ return;
+ }
+
const structuredContext = extractErrorContext(errorContext);
captureException(error, {
diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts
index 44ced3c5b3c3..286446ed90c0 100644
--- a/packages/nitro/src/runtime/plugins/server.ts
+++ b/packages/nitro/src/runtime/plugins/server.ts
@@ -4,15 +4,22 @@ import {
} from '@sentry/core/server';
import { definePlugin } from 'nitro';
import { useRuntimeConfig } from 'nitro/runtime-config';
+// @ts-expect-error - This is a virtual module
+import { tasks } from '#nitro/virtual/tasks';
import { captureErrorHook } from '../hooks/captureErrorHook';
+import { instrumentTasks } from '../utils/instrumentTasks';
export default definePlugin(nitroApp => {
nitroApp.hooks.hook('error', captureErrorHook);
+ instrumentTasks(tasks);
// Nitro runs the app's `middleware/` before anything a module registers, so an auth middleware there would block
// the tunnel. Every preset hands requests to `nitroApp.fetch` after the plugins ran, so wrapping it goes first,
- // before route rules and middleware. Requests still carry Nitro's `baseURL` here, so the tunnel is served under it.
- setManagedTunnelDefaults({ basePath: (useRuntimeConfig() as { app?: { baseURL?: string } }).app?.baseURL });
+ // before route rules and middleware. Requests still carry Nitro's `baseURL` here, so the tunnel is served under it,
+ // unless a framework serves the app under another prefix: `@sentry/solidstart` sets `sentryTunnelBasePath` to Vite's
+ // `base`.
+ const runtimeConfig = useRuntimeConfig() as { app?: { baseURL?: string }; sentryTunnelBasePath?: string };
+ setManagedTunnelDefaults({ basePath: runtimeConfig.sentryTunnelBasePath || runtimeConfig.app?.baseURL });
const appFetch = nitroApp.fetch;
nitroApp.fetch = request => handleServedTunnelRequest(request) ?? appFetch(request);
});
diff --git a/packages/nitro/src/runtime/utils/instrumentTasks.ts b/packages/nitro/src/runtime/utils/instrumentTasks.ts
new file mode 100644
index 000000000000..b4bbf884a620
--- /dev/null
+++ b/packages/nitro/src/runtime/utils/instrumentTasks.ts
@@ -0,0 +1,43 @@
+import { captureException } from '@sentry/core';
+import { flushIfServerless } from '@sentry/core/server';
+import type { Task } from 'nitro/types';
+
+type TaskRegistry = Record Promise }>;
+
+/**
+ * Wraps the `run` function of every registered Nitro task to capture errors it throws.
+ *
+ * Nitro's scheduler catches task errors and only logs them, so they never reach the `error` hook.
+ */
+export function instrumentTasks(tasks: TaskRegistry): void {
+ for (const [name, task] of Object.entries(tasks)) {
+ const originalResolve = task.resolve;
+ if (!originalResolve) {
+ continue;
+ }
+
+ task.resolve = async () => {
+ const handler = await originalResolve();
+ if (typeof handler?.run !== 'function') {
+ return handler;
+ }
+
+ // `resolve` returns the same cached module object on every call, so return a copy instead of wrapping `run` again.
+ return {
+ ...handler,
+ run: async event => {
+ try {
+ return await handler.run(event);
+ } catch (error) {
+ captureException(error, {
+ captureContext: { contexts: { nitro: { task: name } } },
+ mechanism: { handled: false, type: 'auto.function.nitro.task' },
+ });
+ await flushIfServerless();
+ throw error;
+ }
+ },
+ };
+ };
+ }
+}
diff --git a/packages/nitro/test/module.test.ts b/packages/nitro/test/module.test.ts
index d65698d6eee0..c293176a5bd8 100644
--- a/packages/nitro/test/module.test.ts
+++ b/packages/nitro/test/module.test.ts
@@ -19,8 +19,8 @@ function setClient(managedTunnel: object): Record {
return options;
}
-function setup(options: { dev: boolean; baseURL: string }): void {
- (createNitroModule().setup as (nitro: unknown) => void)({ options });
+function setup(options: { dev: boolean; baseURL: string; runtimeConfig?: Record }): void {
+ (createNitroModule().setup as (nitro: unknown) => void)({ options: { runtimeConfig: {}, ...options } });
}
describe('createNitroModule', () => {
@@ -44,4 +44,12 @@ describe('createNitroModule', () => {
expect(options._managedTunnel).toEqual({});
});
+
+ it("serves the dev server's managed tunnel under a framework's `sentryTunnelBasePath`", () => {
+ const options = setClient({});
+
+ setup({ dev: true, baseURL: '/', runtimeConfig: { sentryTunnelBasePath: '/app' } });
+
+ expect(options._managedTunnel).toEqual({ basePath: '/app' });
+ });
});
diff --git a/packages/nitro/test/runtime/hooks/captureErrorHook.test.ts b/packages/nitro/test/runtime/hooks/captureErrorHook.test.ts
index 7ff08f489a0d..a692c1667dcb 100644
--- a/packages/nitro/test/runtime/hooks/captureErrorHook.test.ts
+++ b/packages/nitro/test/runtime/hooks/captureErrorHook.test.ts
@@ -98,6 +98,16 @@ describe('captureErrorHook', () => {
);
});
+ it('should skip HTTPError whose cause was already captured', async () => {
+ const cause = new Error('Task error');
+ Object.defineProperty(cause, '__sentry_captured__', { value: true });
+ const error = new HTTPError(cause);
+
+ await captureErrorHook(error, mockErrorContext);
+
+ expect(SentryCore.captureException).not.toHaveBeenCalled();
+ });
+
it('should skip when enableNitroErrorHandler is false', async () => {
(SentryCore.getClient as any).mockReturnValue({
getOptions: () => ({ enableNitroErrorHandler: false }),
diff --git a/packages/nitro/test/runtime/utils/instrumentTasks.test.ts b/packages/nitro/test/runtime/utils/instrumentTasks.test.ts
new file mode 100644
index 000000000000..dd093e92c557
--- /dev/null
+++ b/packages/nitro/test/runtime/utils/instrumentTasks.test.ts
@@ -0,0 +1,67 @@
+import * as SentryCore from '@sentry/core';
+import type { Task } from 'nitro/types';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { instrumentTasks } from '../../../src/runtime/utils/instrumentTasks';
+
+vi.mock('@sentry/core', async importOriginal => {
+ const mod = await importOriginal();
+ return {
+ ...(mod as any),
+ captureException: vi.fn(),
+ };
+});
+
+const taskEvent = { name: 'test-task', payload: {}, context: {} };
+
+describe('instrumentTasks', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('captures and rethrows errors thrown by a task', async () => {
+ const error = new Error('Task failed');
+ const task: Task = { run: () => Promise.reject(error) };
+ const tasks = { 'test-task': { resolve: () => Promise.resolve(task) } };
+
+ instrumentTasks(tasks);
+ const handler = await tasks['test-task'].resolve();
+
+ await expect(handler.run(taskEvent)).rejects.toBe(error);
+ expect(SentryCore.captureException).toHaveBeenCalledWith(error, {
+ captureContext: { contexts: { nitro: { task: 'test-task' } } },
+ mechanism: { handled: false, type: 'auto.function.nitro.task' },
+ });
+ });
+
+ it('returns the task result and does not capture on success', async () => {
+ const task: Task = { meta: { description: 'desc' }, run: () => Promise.resolve({ result: 'ok' }) };
+ const tasks = { 'test-task': { resolve: () => Promise.resolve(task) } };
+
+ instrumentTasks(tasks);
+ const handler = await tasks['test-task'].resolve();
+
+ await expect(handler.run(taskEvent)).resolves.toEqual({ result: 'ok' });
+ expect(handler.meta).toEqual({ description: 'desc' });
+ expect(SentryCore.captureException).not.toHaveBeenCalled();
+ });
+
+ it('does not wrap the cached task again on repeated resolves', async () => {
+ const error = new Error('Task failed');
+ const task: Task = { run: () => Promise.reject(error) };
+ const tasks = { 'test-task': { resolve: () => Promise.resolve(task) } };
+
+ instrumentTasks(tasks);
+ await tasks['test-task'].resolve();
+ const handler = await tasks['test-task'].resolve();
+
+ await expect(handler.run(taskEvent)).rejects.toBe(error);
+ expect(SentryCore.captureException).toHaveBeenCalledTimes(1);
+ });
+
+ it('skips tasks without a handler', () => {
+ const tasks = { 'test-task': { resolve: undefined } };
+
+ expect(() => instrumentTasks(tasks)).not.toThrow();
+ expect(tasks['test-task'].resolve).toBeUndefined();
+ });
+});
diff --git a/packages/node/package.json b/packages/node/package.json
index 49a81bbe88a9..230d3fa3e277 100644
--- a/packages/node/package.json
+++ b/packages/node/package.json
@@ -78,7 +78,7 @@
},
"dependencies": {
"@opentelemetry/api": "^1.9.1",
- "@sentry/conventions": "^0.29.0",
+ "@sentry/conventions": "^0.30.0",
"@sentry/core": "11.10.0",
"@sentry/opentelemetry": "11.10.0",
"@sentry/server-runtime-injection": "11.10.0",
diff --git a/packages/node/src/cron/cron.ts b/packages/node/src/cron/cron.ts
index 2c764c877c46..dd3edf1c95b5 100644
--- a/packages/node/src/cron/cron.ts
+++ b/packages/node/src/cron/cron.ts
@@ -1,4 +1,4 @@
-import { captureException, withMonitor } from '@sentry/core';
+import { addNonEnumerableProperty, captureException, withMonitor } from '@sentry/core';
import { replaceCronNames } from './common';
export type CronJobParams = {
@@ -51,6 +51,8 @@ export type CronJobConstructor = {
): CronJob;
};
+const MONITORED = Symbol.for('sentry.cron.monitored');
+
const ERROR_TEXT = 'Automatic instrumentation of CronJob only supports crontab string';
/**
@@ -72,6 +74,8 @@ const ERROR_TEXT = 'Automatic instrumentation of CronJob only supports crontab s
* console.log('You will see this message every minute');
* });
* ```
+ *
+ * @deprecated Use `cronJobIntegration` instead.
*/
export function instrumentCron(lib: T & CronJobConstructor, monitorSlug: string): T {
let jobScheduled = false;
@@ -115,6 +119,9 @@ export function instrumentCron(lib: T & CronJobConstructor, monitorSlug: stri
);
}
+ // Tells `cronJobIntegration` that this job is already monitored.
+ addNonEnumerableProperty(monitoredTick, MONITORED, true);
+
return new target(cronTime, monitoredTick, onComplete, start, timeZone, ...rest);
},
get(target, prop: keyof CronJobConstructor) {
@@ -157,6 +164,8 @@ export function instrumentCron(lib: T & CronJobConstructor, monitorSlug: stri
);
};
+ addNonEnumerableProperty(param.onTick, MONITORED, true);
+
return target.from(param);
};
} else {
diff --git a/packages/node/src/cron/index.ts b/packages/node/src/cron/index.ts
index eb4b915cff66..890d88441ec1 100644
--- a/packages/node/src/cron/index.ts
+++ b/packages/node/src/cron/index.ts
@@ -4,6 +4,7 @@ import { instrumentNodeSchedule } from './node-schedule';
/** Methods to instrument cron libraries for Sentry check-ins */
export const cron = {
+ // oxlint-disable-next-line typescript/no-deprecated
instrumentCron,
instrumentNodeCron,
instrumentNodeSchedule,
diff --git a/packages/node/src/cron/node-cron.ts b/packages/node/src/cron/node-cron.ts
index db3445fe8471..ab3d935c1a89 100644
--- a/packages/node/src/cron/node-cron.ts
+++ b/packages/node/src/cron/node-cron.ts
@@ -1,4 +1,4 @@
-import { captureException, type MonitorConfig, withMonitor } from '@sentry/core';
+import { addNonEnumerableProperty, captureException, type MonitorConfig, withMonitor } from '@sentry/core';
import { replaceCronNames } from './common';
export interface NodeCronOptions {
@@ -78,6 +78,9 @@ export function instrumentNodeCron(
);
};
+ // Tells `nodeCronIntegration` that this task is already monitored.
+ addNonEnumerableProperty(monitoredCallback, Symbol.for('sentry.cron.monitored'), true);
+
return target.apply(thisArg, [expression, monitoredCallback, options]);
},
});
diff --git a/packages/node/src/cron/node-schedule.ts b/packages/node/src/cron/node-schedule.ts
index f174ef7c904b..548f60ac48f8 100644
--- a/packages/node/src/cron/node-schedule.ts
+++ b/packages/node/src/cron/node-schedule.ts
@@ -1,4 +1,4 @@
-import { withMonitor } from '@sentry/core';
+import { addNonEnumerableProperty, withMonitor } from '@sentry/core';
import { replaceCronNames } from './common';
export interface NodeSchedule {
@@ -58,6 +58,9 @@ export function instrumentNodeSchedule(lib: T & NodeSchedule): T {
);
}
+ // Tells `nodeScheduleIntegration` that this job is already monitored.
+ addNonEnumerableProperty(monitoredCallback, Symbol.for('sentry.cron.monitored'), true);
+
return target.apply(thisArg, [monitorSlug, expression, monitoredCallback]);
},
});
diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts
index 53f1b781d45c..859d3b02719a 100644
--- a/packages/node/src/index.ts
+++ b/packages/node/src/index.ts
@@ -1,4 +1,5 @@
export { httpIntegration } from './integrations/http';
+export { awsLambdaWebIntegration } from './integrations/awsLambdaWeb';
export { nativeNodeFetchIntegration } from './integrations/node-fetch';
export { fsIntegration } from './integrations/fs';
// oxlint-disable-next-line typescript/no-deprecated
@@ -12,6 +13,12 @@ export {
type ExpressIntegrationOptions,
firebaseIntegration,
genericPoolIntegration,
+ cronJobIntegration,
+ type CronJobIntegrationOptions,
+ nodeCronIntegration,
+ type NodeCronIntegrationOptions,
+ nodeScheduleIntegration,
+ type NodeScheduleIntegrationOptions,
googleGenAIIntegration,
graphqlIntegration,
groqIntegration,
@@ -68,6 +75,7 @@ export {
export { setupHapiErrorHandler } from './integrations/tracing/hapi';
// oxlint-disable-next-line typescript/no-deprecated -- deprecated but still re-exported for backwards compatibility
export { setupKoaErrorHandler } from './integrations/tracing/koa';
+export { BullMQTelemetry } from './integrations/tracing/bullmq';
export {
launchDarklyIntegration,
buildLaunchDarklyFlagUsedHandler,
diff --git a/packages/node/src/integrations/awsLambdaWeb.ts b/packages/node/src/integrations/awsLambdaWeb.ts
new file mode 100644
index 000000000000..42b3c5e5d86e
--- /dev/null
+++ b/packages/node/src/integrations/awsLambdaWeb.ts
@@ -0,0 +1,76 @@
+import {
+ AWS_LAMBDA_INITIALIZATION_TYPE,
+ AWS_LAMBDA_WEB_ENDPOINT_NAME,
+ FAAS_COLDSTART,
+ FAAS_INVOCATION_ID,
+ FAAS_MAX_MEMORY,
+} from '@sentry/conventions/attributes';
+import type { IntegrationFn, SpanAttributes } from '@sentry/core';
+import { defineIntegration, getIsolationScope, safeSetSpanJSONAttributes } from '@sentry/core';
+
+const INTEGRATION_NAME = 'AwsLambdaWeb';
+
+let requestHandled = false;
+
+const _awsLambdaWebIntegration = (() => {
+ // Lambda Web Functions run ordinary HTTP servers without a Lambda handler to wrap.
+ // We read their request metadata through the `httpServerRequest` hook. Since this hook
+ // runs for all HTTP servers, we bail out early when we are not in a Lambda Web Function.
+ // https://docs.aws.amazon.com/lambda/latest/dg/web-functions-revisions-env-vars.html
+ if (!process.env.AWS_LAMBDA_HTTP_ENDPOINT) {
+ return { name: INTEGRATION_NAME };
+ }
+
+ const memory = Number(process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE) * 1024 * 1024;
+ const lambdaContext = {
+ memory_size: Number.isFinite(memory) && memory > 0 ? memory : undefined,
+ initialization_type: process.env.AWS_LAMBDA_INITIALIZATION_TYPE,
+ };
+ const environmentAttributes = {
+ [FAAS_MAX_MEMORY]: lambdaContext.memory_size,
+ [AWS_LAMBDA_INITIALIZATION_TYPE]: lambdaContext.initialization_type,
+ };
+
+ return {
+ name: INTEGRATION_NAME,
+ setup(client) {
+ client.on('httpServerRequest', (_request, _response, request) => {
+ const coldStart = !requestHandled;
+ requestHandled = true;
+ const requestId = request.headers?.['lambda-web-request-id'];
+ const endpointName = request.headers?.['lambda-web-endpoint-name'];
+ const scope = getIsolationScope();
+ scope.setContext('aws.lambda', {
+ ...scope.getScopeData().contexts['aws.lambda'],
+ cold_start: coldStart,
+ aws_request_id: typeof requestId === 'string' ? requestId : undefined,
+ endpoint_name: typeof endpointName === 'string' ? endpointName : undefined,
+ });
+ });
+ },
+ processEvent(event) {
+ event.contexts = {
+ ...event.contexts,
+ 'aws.lambda': { ...lambdaContext, ...event.contexts?.['aws.lambda'] },
+ };
+ return event;
+ },
+ processSegmentSpan(span) {
+ const requestContext = getIsolationScope().getScopeData().contexts['aws.lambda'];
+ const attributes: SpanAttributes = { ...environmentAttributes };
+ if (typeof requestContext?.cold_start === 'boolean') {
+ attributes[FAAS_COLDSTART] = requestContext.cold_start;
+ }
+ if (typeof requestContext?.aws_request_id === 'string') {
+ attributes[FAAS_INVOCATION_ID] = requestContext.aws_request_id;
+ }
+ if (typeof requestContext?.endpoint_name === 'string') {
+ attributes[AWS_LAMBDA_WEB_ENDPOINT_NAME] = requestContext.endpoint_name;
+ }
+ safeSetSpanJSONAttributes(span, attributes);
+ },
+ };
+}) satisfies IntegrationFn;
+
+/** Enrich AWS Lambda Web Function spans and error contexts with platform metadata. */
+export const awsLambdaWebIntegration = defineIntegration(_awsLambdaWebIntegration);
diff --git a/packages/node/src/integrations/context.ts b/packages/node/src/integrations/context.ts
index c35f74b350c6..b1fa7d36598c 100644
--- a/packages/node/src/integrations/context.ts
+++ b/packages/node/src/integrations/context.ts
@@ -1,5 +1,28 @@
/* eslint-disable max-lines */
+import {
+ APP_START_TIME,
+ CLOUD_ACCOUNT_ID,
+ CLOUD_AVAILABILITY_ZONE,
+ CLOUD_PLATFORM,
+ CLOUD_PROVIDER,
+ CLOUD_REGION,
+ CULTURE_LOCALE,
+ CULTURE_TIMEZONE,
+ DEVICE_ARCHS,
+ DEVICE_BOOT_TIME,
+ DEVICE_CPU_DESCRIPTION,
+ DEVICE_FREE_MEMORY,
+ DEVICE_MEMORY_SIZE,
+ DEVICE_PROCESSOR_COUNT,
+ DEVICE_PROCESSOR_FREQUENCY,
+ OS_BUILD,
+ OS_KERNEL_VERSION,
+ OS_NAME,
+ OS_VERSION,
+ PROCESS_RUNTIME_ENGINE_NAME,
+ PROCESS_RUNTIME_ENGINE_VERSION,
+} from '@sentry/conventions/attributes';
import { execFile } from 'node:child_process';
import { readdir, readFile } from 'node:fs';
import * as os from 'node:os';
@@ -54,8 +77,8 @@ const _nodeContextIntegration = ((options: ContextOptions = {}) => {
// Map static context data to span attributes
const cachedSpanAttributes: Record = {
- 'process.runtime.engine.name': 'v8',
- 'process.runtime.engine.version': process.versions.v8,
+ [PROCESS_RUNTIME_ENGINE_NAME]: 'v8',
+ [PROCESS_RUNTIME_ENGINE_VERSION]: process.versions.v8,
...contextsToSpanAttributes({
app: appContext,
device: deviceContext,
@@ -156,52 +179,53 @@ export function contextsToSpanAttributes(contexts: Contexts): Record Math.max(0, (ns - resolutionNs) / 1e9);
- const METRIC_ATTRIBUTES = { attributes: { 'sentry.origin': 'auto.node.runtime_metrics' } };
- const METRIC_ATTRIBUTES_BYTE = { unit: 'byte', attributes: { 'sentry.origin': 'auto.node.runtime_metrics' } };
- const METRIC_ATTRIBUTES_SECOND = { unit: 'second', attributes: { 'sentry.origin': 'auto.node.runtime_metrics' } };
+ const METRIC_ATTRIBUTES = { attributes: { [SENTRY_ORIGIN]: 'auto.node.runtime_metrics' } };
+ const METRIC_ATTRIBUTES_BYTE = { unit: 'byte', attributes: { [SENTRY_ORIGIN]: 'auto.node.runtime_metrics' } };
+ const METRIC_ATTRIBUTES_SECOND = { unit: 'second', attributes: { [SENTRY_ORIGIN]: 'auto.node.runtime_metrics' } };
function collectMetrics(): void {
const now = _INTERNAL_safeDateNow();
diff --git a/packages/node/src/integrations/pino.ts b/packages/node/src/integrations/pino.ts
index c5560f0c34d5..e058975a3511 100644
--- a/packages/node/src/integrations/pino.ts
+++ b/packages/node/src/integrations/pino.ts
@@ -1,3 +1,4 @@
+import { SENTRY_ORIGIN } from '@sentry/conventions/attributes';
import type { IntegrationFn, LogSeverityLevel } from '@sentry/core';
import {
_INTERNAL_captureLog,
@@ -142,7 +143,7 @@ const _pinoIntegration = defineIntegration((userOptions: DeepPartial = {
...resultObj,
- 'sentry.origin': 'auto.log.pino',
+ [SENTRY_ORIGIN]: 'auto.log.pino',
'pino.logger.level': levelNumber,
};
diff --git a/packages/node/src/integrations/tracing/bullmq/contextManager.ts b/packages/node/src/integrations/tracing/bullmq/contextManager.ts
new file mode 100644
index 000000000000..ad32e5689cdb
--- /dev/null
+++ b/packages/node/src/integrations/tracing/bullmq/contextManager.ts
@@ -0,0 +1,62 @@
+import type { Span } from '@opentelemetry/api';
+import {
+ extractTraceparentData,
+ getActiveSpan,
+ getCurrentScope,
+ spanToTraceHeader,
+ withActiveSpan,
+ withIsolationScope,
+} from '@sentry/core';
+import type { ContextManager, SentryContext } from './types';
+
+export class SentryBullMQContextManager implements ContextManager {
+ public active(): SentryContext {
+ return {
+ span: getActiveSpan(),
+ scope: getCurrentScope(),
+ };
+ }
+
+ public with unknown>(context: SentryContext, fn: A): ReturnType {
+ if (context.span) {
+ return withIsolationScope(() => {
+ return withActiveSpan(context.span as Span, fn) as ReturnType ;
+ });
+ }
+ return withIsolationScope(() => withActiveSpan(null, fn)) as ReturnType ;
+ }
+
+ /**
+ * Returns a context without a span. BullMQ 6.3.11+ runs the stalled jobs checker in it, so the
+ * checker does not stay attached to the span that was active when the worker started.
+ */
+ public root(): SentryContext {
+ return {
+ span: undefined,
+ scope: getCurrentScope(),
+ };
+ }
+
+ public getMetadata(context: SentryContext): string {
+ if (context.span) {
+ return spanToTraceHeader(context.span);
+ }
+ return '';
+ }
+
+ public fromMetadata(activeContext: SentryContext, metadata: string): SentryContext {
+ const traceparent = extractTraceparentData(metadata);
+ if (!traceparent?.traceId || !traceparent?.parentSpanId) {
+ return activeContext;
+ }
+
+ return {
+ ...activeContext,
+ producerSpanContext: {
+ traceId: traceparent.traceId,
+ spanId: traceparent.parentSpanId,
+ sampled: traceparent.parentSampled === true,
+ },
+ };
+ }
+}
diff --git a/packages/node/src/integrations/tracing/bullmq/index.ts b/packages/node/src/integrations/tracing/bullmq/index.ts
new file mode 100644
index 000000000000..984b1af13591
--- /dev/null
+++ b/packages/node/src/integrations/tracing/bullmq/index.ts
@@ -0,0 +1,56 @@
+import { SentryBullMQContextManager } from './contextManager';
+import { SentryBullMQMeter } from './meter';
+import { SentryBullMQTracer } from './tracer';
+import type { ContextManager, Meter, Telemetry, Tracer, SentryContext } from './types';
+
+/**
+ * A Sentry-specific telemetry implementation for BullMQ.
+ *
+ * This implements BullMQ's Telemetry interface to provide automatic tracing
+ * for queue operations with proper Queue Insights attributes.
+ *
+ * @example
+ * ```javascript
+ * import * as Sentry from '@sentry/node';
+ * import { Queue, Worker } from 'bullmq';
+ *
+ * const telemetry = new Sentry.BullMQTelemetry();
+ *
+ * const queue = new Queue('myQueue', {
+ * connection: { host: '127.0.0.1', port: 6379 },
+ * telemetry,
+ * });
+ *
+ * const worker = new Worker('myQueue', async (job) => {
+ * // Process job
+ * }, {
+ * connection: { host: '127.0.0.1', port: 6379 },
+ * telemetry,
+ * });
+ * ```
+ *
+ * To also send BullMQ's job metrics to Sentry, use `new Sentry.BullMQTelemetry({ enableMetrics: true })`.
+ *
+ * @see https://docs.bullmq.io/guide/telemetry
+ */
+export class BullMQTelemetry implements Telemetry {
+ public tracer: Tracer;
+ public contextManager: ContextManager;
+ public meter?: Meter;
+
+ public constructor(
+ options: {
+ /**
+ * Sends BullMQ's job metrics, such as `messaging.bullmq.jobs.completed` and `messaging.process.duration`, to Sentry. Defaults to `false`.
+ */
+ enableMetrics?: boolean;
+ } = {},
+ ) {
+ this.tracer = new SentryBullMQTracer();
+ this.contextManager = new SentryBullMQContextManager();
+
+ if (options.enableMetrics) {
+ this.meter = new SentryBullMQMeter();
+ }
+ }
+}
diff --git a/packages/node/src/integrations/tracing/bullmq/meter.ts b/packages/node/src/integrations/tracing/bullmq/meter.ts
new file mode 100644
index 000000000000..bfae7aa342f8
--- /dev/null
+++ b/packages/node/src/integrations/tracing/bullmq/meter.ts
@@ -0,0 +1,121 @@
+import { MESSAGING_OPERATION_NAME, MESSAGING_SYSTEM } from '@sentry/conventions/attributes';
+import { metrics } from '@sentry/core';
+import { toMessagingKey, toSentryAttributes } from './span';
+import type { AttributeValue, Counter, Gauge, Histogram, Meter, MetricOptions } from './types';
+
+function toMetricAttributes(
+ attributes?: Record,
+): Record | undefined {
+ if (!attributes) {
+ return undefined;
+ }
+ const result: Record = {};
+ for (const [key, value] of Object.entries(toSentryAttributes(attributes))) {
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
+ result[key] = value;
+ }
+ }
+ return result;
+}
+
+/**
+ * Converts the OpenTelemetry (UCUM) unit that BullMQ passes to the matching Sentry unit.
+ */
+function toSentryUnit(unit?: string): string | undefined {
+ if (unit === 'ms') {
+ return 'millisecond';
+ }
+
+ // In UCUM, `1` and annotations such as `{jobs}` mark a value without a unit.
+ if (unit === '1' || unit?.startsWith('{')) {
+ return 'none';
+ }
+
+ return unit;
+}
+
+class SentryBullMQCounter implements Counter {
+ private _name: string;
+ private _unit?: string;
+
+ public constructor(name: string, unit?: string) {
+ this._name = name;
+ this._unit = unit;
+ }
+
+ public add(value: number, attributes?: Record): void {
+ metrics.count(this._name, value, {
+ unit: this._unit,
+ attributes: toMetricAttributes(attributes),
+ });
+ }
+}
+
+class SentryBullMQHistogram implements Histogram {
+ private _name: string;
+ private _unit?: string;
+
+ public constructor(name: string, unit?: string) {
+ this._name = name;
+ this._unit = unit;
+ }
+
+ public record(value: number, attributes?: Record): void {
+ metrics.distribution(this._name, value, {
+ unit: this._unit,
+ attributes: toMetricAttributes(attributes),
+ });
+ }
+}
+
+class SentryBullMQGauge implements Gauge {
+ private _name: string;
+ private _unit?: string;
+
+ public constructor(name: string, unit?: string) {
+ this._name = name;
+ this._unit = unit;
+ }
+
+ public record(value: number, attributes?: Record): void {
+ metrics.gauge(this._name, value, {
+ unit: this._unit,
+ attributes: toMetricAttributes(attributes),
+ });
+ }
+}
+
+/**
+ * Records BullMQ's `bullmq.job.duration` histogram, which is in milliseconds, as the OTel
+ * `messaging.process.duration` metric in seconds.
+ */
+class SentryBullMQProcessDuration implements Histogram {
+ public record(value: number, attributes?: Record): void {
+ metrics.distribution('messaging.process.duration', value / 1000, {
+ unit: 'second',
+ attributes: {
+ ...toMetricAttributes(attributes),
+ [MESSAGING_SYSTEM]: 'bullmq',
+ [MESSAGING_OPERATION_NAME]: 'process',
+ },
+ });
+ }
+}
+
+export class SentryBullMQMeter implements Meter {
+ public createCounter(name: string, options?: MetricOptions): Counter {
+ return new SentryBullMQCounter(toMessagingKey(name), toSentryUnit(options?.unit));
+ }
+
+ public createHistogram(name: string, options?: MetricOptions): Histogram {
+ if (name === 'bullmq.job.duration') {
+ return new SentryBullMQProcessDuration();
+ }
+
+ return new SentryBullMQHistogram(toMessagingKey(name), toSentryUnit(options?.unit));
+ }
+
+ public createGauge(name: string, options?: MetricOptions): Gauge {
+ return new SentryBullMQGauge(toMessagingKey(name), toSentryUnit(options?.unit));
+ }
+}
diff --git a/packages/node/src/integrations/tracing/bullmq/span.ts b/packages/node/src/integrations/tracing/bullmq/span.ts
new file mode 100644
index 000000000000..10ec13bee4c2
--- /dev/null
+++ b/packages/node/src/integrations/tracing/bullmq/span.ts
@@ -0,0 +1,205 @@
+import type { Attributes, Span } from '@opentelemetry/api';
+import {
+ MESSAGING_BATCH_MESSAGE_COUNT,
+ MESSAGING_BULLMQ_FLOW_NAME,
+ MESSAGING_BULLMQ_JOB_ATTEMPT_FINISHED_TIMESTAMP,
+ MESSAGING_BULLMQ_JOB_BULK_NAMES,
+ MESSAGING_BULLMQ_JOB_DEDUPLICATION_KEY,
+ MESSAGING_BULLMQ_JOB_IDS,
+ MESSAGING_BULLMQ_JOB_KEY,
+ MESSAGING_BULLMQ_JOB_NAME,
+ MESSAGING_BULLMQ_JOB_PROCESSED_TIMESTAMP,
+ MESSAGING_BULLMQ_JOB_SCHEDULER_ID,
+ MESSAGING_BULLMQ_JOB_STATE,
+ MESSAGING_BULLMQ_JOB_TYPE,
+ MESSAGING_BULLMQ_QUEUE_CLEAN_COUNT,
+ MESSAGING_BULLMQ_QUEUE_CLEAN_LIMIT,
+ MESSAGING_BULLMQ_QUEUE_DRAIN_DELAY,
+ MESSAGING_BULLMQ_QUEUE_EVENT_MAX_LENGTH,
+ MESSAGING_BULLMQ_QUEUE_GRACE,
+ MESSAGING_BULLMQ_QUEUE_JOBS_STATE,
+ MESSAGING_BULLMQ_QUEUE_RATE_LIMIT,
+ MESSAGING_BULLMQ_WORKER_DO_NOT_WAIT_ACTIVE,
+ MESSAGING_BULLMQ_WORKER_FORCE_CLOSE,
+ MESSAGING_BULLMQ_WORKER_JOBS_TO_EXTEND_LOCKS,
+ MESSAGING_BULLMQ_WORKER_NAME,
+ MESSAGING_BULLMQ_WORKER_RATE_LIMIT,
+ MESSAGING_BULLMQ_WORKER_STALLED_JOBS,
+ MESSAGING_CLIENT_ID,
+ MESSAGING_DESTINATION_NAME,
+ MESSAGING_MESSAGE_ID,
+ MESSAGING_MESSAGE_RETRY_COUNT,
+ MESSAGING_OPERATION_NAME,
+} from '@sentry/conventions/attributes';
+import type { Scope } from '@sentry/core';
+import { captureException, SPAN_STATUS_ERROR } from '@sentry/core';
+import type { AttributeValue, TelemetrySpan } from './types';
+
+const DROPPED_ATTRIBUTES = new Set([
+ // BullMQ sets these to `JSON.stringify()` output. They are left out until Sentry attributes support objects.
+ 'bullmq.job.options',
+ 'bullmq.job.progress',
+ 'bullmq.queue.options',
+ 'bullmq.worker.options',
+ // BullMQ deprecated this for `bullmq.job.attempt_finished_timestamp`, which it sets on the same spans.
+ 'bullmq.job.finished.timestamp',
+]);
+
+// The BullMQ attribute keys and their `messaging.bullmq.*` keys in the Sentry conventions. Where BullMQ separates
+// the words of a key with dots, the Sentry key uses snake_case.
+const MESSAGING_BULLMQ_KEYS = new Map([
+ ['bullmq.flow.name', MESSAGING_BULLMQ_FLOW_NAME],
+ ['bullmq.job.attempt_finished_timestamp', MESSAGING_BULLMQ_JOB_ATTEMPT_FINISHED_TIMESTAMP],
+ ['bullmq.job.bulk.names', MESSAGING_BULLMQ_JOB_BULK_NAMES],
+ ['bullmq.job.deduplication.key', MESSAGING_BULLMQ_JOB_DEDUPLICATION_KEY],
+ ['bullmq.job.ids', MESSAGING_BULLMQ_JOB_IDS],
+ ['bullmq.job.key', MESSAGING_BULLMQ_JOB_KEY],
+ ['bullmq.job.name', MESSAGING_BULLMQ_JOB_NAME],
+ ['bullmq.job.processed.timestamp', MESSAGING_BULLMQ_JOB_PROCESSED_TIMESTAMP],
+ ['bullmq.job.scheduler.id', MESSAGING_BULLMQ_JOB_SCHEDULER_ID],
+ ['bullmq.job.type', MESSAGING_BULLMQ_JOB_TYPE],
+ ['bullmq.queue.clean.count', MESSAGING_BULLMQ_QUEUE_CLEAN_COUNT],
+ ['bullmq.queue.clean.limit', MESSAGING_BULLMQ_QUEUE_CLEAN_LIMIT],
+ ['bullmq.queue.drain.delay', MESSAGING_BULLMQ_QUEUE_DRAIN_DELAY],
+ ['bullmq.queue.event.max.length', MESSAGING_BULLMQ_QUEUE_EVENT_MAX_LENGTH],
+ ['bullmq.queue.grace', MESSAGING_BULLMQ_QUEUE_GRACE],
+ ['bullmq.queue.jobs.state', MESSAGING_BULLMQ_QUEUE_JOBS_STATE],
+ ['bullmq.queue.rate.limit', MESSAGING_BULLMQ_QUEUE_RATE_LIMIT],
+ ['bullmq.worker.do.not.wait.active', MESSAGING_BULLMQ_WORKER_DO_NOT_WAIT_ACTIVE],
+ ['bullmq.worker.force.close', MESSAGING_BULLMQ_WORKER_FORCE_CLOSE],
+ ['bullmq.worker.jobs.to.extend.locks', MESSAGING_BULLMQ_WORKER_JOBS_TO_EXTEND_LOCKS],
+ ['bullmq.worker.name', MESSAGING_BULLMQ_WORKER_NAME],
+ ['bullmq.worker.rate.limit', MESSAGING_BULLMQ_WORKER_RATE_LIMIT],
+ ['bullmq.worker.stalled.jobs', MESSAGING_BULLMQ_WORKER_STALLED_JOBS],
+]);
+
+/**
+ * Returns the Sentry key for a `bullmq.*` attribute key or metric name: the key from `MESSAGING_BULLMQ_KEYS`, or
+ * `messaging.` for the keys that are not in it, such as the metric names. Returns all other keys unchanged.
+ */
+export function toMessagingKey(key: string): string {
+ return MESSAGING_BULLMQ_KEYS.get(key) ?? (key.startsWith('bullmq.') ? `messaging.${key}` : key);
+}
+
+/**
+ * Returns the BullMQ attributes with Sentry keys: the `messaging.*` convention where one exists, and the
+ * `messaging.bullmq.*` key for all others. The attributes that hold JSON strings and the deprecated ones are left out.
+ */
+export function toSentryAttributes(attributes: Record): Attributes {
+ const {
+ 'bullmq.queue.name': queueName,
+ 'bullmq.queue.operation': operation,
+ 'bullmq.job.id': jobId,
+ 'bullmq.job.attempts.made': attemptsMade,
+ 'bullmq.job.bulk.count': bulkCount,
+ 'bullmq.worker.id': workerId,
+ 'bullmq.job.state': jobState,
+ 'bullmq.job.status': jobStatus,
+ ...rest
+ } = attributes;
+ const result: Attributes = {};
+
+ for (const [key, value] of Object.entries(rest)) {
+ if (!DROPPED_ATTRIBUTES.has(key)) {
+ result[toMessagingKey(key)] = value;
+ }
+ }
+
+ if (typeof queueName === 'string') {
+ result[MESSAGING_DESTINATION_NAME] = queueName;
+ }
+
+ if (typeof operation === 'string') {
+ result[MESSAGING_OPERATION_NAME] = operation;
+ }
+
+ if (jobId !== undefined) {
+ result[MESSAGING_MESSAGE_ID] = String(jobId);
+ }
+
+ // BullMQ counts the attempt that just finished in `attemptsMade`.
+ if (typeof attemptsMade === 'number') {
+ result[MESSAGING_MESSAGE_RETRY_COUNT] = Math.max(attemptsMade - 1, 0);
+ }
+
+ if (typeof bulkCount === 'number') {
+ result[MESSAGING_BATCH_MESSAGE_COUNT] = bulkCount;
+ }
+
+ if (typeof workerId === 'string') {
+ result[MESSAGING_CLIENT_ID] = workerId;
+ }
+
+ // BullMQ before 5.81.2 sets `bullmq.job.status` instead of `bullmq.job.state`.
+ const state = jobState ?? jobStatus;
+ if (typeof state === 'string') {
+ result[MESSAGING_BULLMQ_JOB_STATE] = state;
+ }
+
+ return result;
+}
+
+export class SentryBullMQSpan implements TelemetrySpan {
+ private _span: Span;
+ private _scope: Scope;
+
+ public constructor(span: Span, scope: Scope) {
+ this._span = span;
+ this._scope = scope;
+ }
+
+ public setAttribute(key: string, value: AttributeValue): void {
+ this.setAttributes({ [key]: value });
+ }
+
+ public setAttributes(attributes: Record): void {
+ this._span.setAttributes(toSentryAttributes(attributes));
+ }
+
+ public addEvent(name: string, attributes?: Record): void {
+ this._span.addEvent(name, attributes);
+
+ // BullMQ sends this event from `Worker.handleFailed`, and the event name is not an exported constant:
+ // https://github.com/taskforcesh/bullmq/blob/01b8b14a973984845dc0bbcefccf35b3ed30782d/src/classes/worker.ts#L1223-L1225
+ if (name === 'job failed') {
+ this._span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
+
+ const reason = attributes?.['bullmq.job.failed.reason'];
+ const error = new Error(String(reason || 'Unknown error'));
+ // BullMQ passes only the error message (https://github.com/taskforcesh/bullmq/issues/4902), so this stack has only
+ // BullMQ and SDK frames, the same for every job. Without it, Sentry groups the errors by their message.
+ error.stack = undefined;
+ captureException(error, {
+ mechanism: {
+ handled: false,
+ type: 'auto.queue.bullmq',
+ },
+ });
+ }
+ }
+
+ /**
+ * Does not capture to Sentry. BullMQ calls this for Redis, lock and serialization errors that it
+ * retries itself, often several times for one fault. Job failures are captured in `addEvent`.
+ */
+ public recordException(exception: Error | string | { code?: number; message?: string; name?: string }): void {
+ const error =
+ exception instanceof Error
+ ? exception
+ : new Error(typeof exception === 'string' ? exception : exception.message || 'Unknown error');
+
+ this._span.recordException(error);
+ }
+
+ public setSpanOnContext(context: unknown): unknown {
+ return {
+ ...(context as object),
+ span: this._span,
+ scope: this._scope,
+ };
+ }
+
+ public end(): void {
+ this._span.end();
+ }
+}
diff --git a/packages/node/src/integrations/tracing/bullmq/tracer.ts b/packages/node/src/integrations/tracing/bullmq/tracer.ts
new file mode 100644
index 000000000000..5722b013e451
--- /dev/null
+++ b/packages/node/src/integrations/tracing/bullmq/tracer.ts
@@ -0,0 +1,80 @@
+import { MESSAGING_SYSTEM, SENTRY_LINK_TYPE, SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes';
+import { QUEUE, QUEUE_PROCESS, QUEUE_PUBLISH } from '@sentry/conventions/op';
+import type { SpanAttributes } from '@sentry/core';
+import { getCurrentScope, startInactiveSpan, startNewTrace } from '@sentry/core';
+import { SentryBullMQSpan, toSentryAttributes } from './span';
+import type { SpanOptions, TelemetrySpan, Tracer, SentryContext } from './types';
+
+// BullMQ span names follow OTel messaging semconv: "{operation} {destination}"
+// e.g. "add myQueue", "addBulk myQueue", "process myQueue", "addFlow myQueue"
+const PRODUCER_OPERATIONS = new Set(['add', 'addbulk', 'addflow', 'addbulkflows']);
+const CONSUMER_OPERATIONS = new Set(['process']);
+
+function getOperation(name: string): string {
+ return name.split(' ')[0]!.toLowerCase();
+}
+
+function getOpFromSpanName(name: string): string {
+ const operation = getOperation(name);
+
+ if (CONSUMER_OPERATIONS.has(operation)) {
+ return QUEUE_PROCESS;
+ }
+
+ if (PRODUCER_OPERATIONS.has(operation)) {
+ return QUEUE_PUBLISH;
+ }
+
+ return QUEUE;
+}
+
+function getOriginFromSpanName(name: string): string {
+ const operation = getOperation(name);
+
+ if (CONSUMER_OPERATIONS.has(operation)) {
+ return 'auto.queue.bullmq.consumer';
+ }
+
+ if (PRODUCER_OPERATIONS.has(operation)) {
+ return 'auto.queue.bullmq.producer';
+ }
+
+ return 'auto.queue.bullmq';
+}
+
+export class SentryBullMQTracer implements Tracer {
+ public startSpan(name: string, options?: SpanOptions, context?: SentryContext): TelemetrySpan {
+ const op = getOpFromSpanName(name);
+ const origin = getOriginFromSpanName(name);
+
+ const attributes: SpanAttributes = {
+ [SENTRY_OP]: op,
+ [SENTRY_ORIGIN]: origin,
+ [MESSAGING_SYSTEM]: 'bullmq',
+ ...(options?.attributes && toSentryAttributes(options.attributes)),
+ };
+
+ // Internal operations such as `moveStalledJobsToWait` run on timers. Without a parent, each tick would start a new root span.
+ const span =
+ op === QUEUE_PROCESS
+ ? startNewTrace(() => startInactiveSpan({ name, attributes }))
+ : startInactiveSpan({ name, attributes, onlyIfParent: op === QUEUE });
+
+ if (context?.producerSpanContext) {
+ const producerSpanCtx = {
+ traceId: context.producerSpanContext.traceId,
+ spanId: context.producerSpanContext.spanId,
+ traceFlags: context.producerSpanContext.sampled ? 1 : 0,
+ };
+
+ span.addLink({
+ context: producerSpanCtx,
+ attributes: {
+ [SENTRY_LINK_TYPE]: 'message_origin',
+ },
+ });
+ }
+
+ return new SentryBullMQSpan(span, getCurrentScope());
+ }
+}
diff --git a/packages/node/src/integrations/tracing/bullmq/types.ts b/packages/node/src/integrations/tracing/bullmq/types.ts
new file mode 100644
index 000000000000..81d2e96696ce
--- /dev/null
+++ b/packages/node/src/integrations/tracing/bullmq/types.ts
@@ -0,0 +1,81 @@
+import type { Span } from '@opentelemetry/api';
+import type { Scope } from '@sentry/core';
+
+/**
+ * Vendored from bullmq@5.79.1 — src/interfaces/telemetry.ts
+ * https://github.com/taskforcesh/bullmq/blob/master/src/interfaces/telemetry.ts
+ *
+ * Minimal subset of BullMQ's Telemetry interface so we don't depend on bullmq at runtime.
+ *
+ * SPDX-License-Identifier: MIT
+ * Copyright (c) 2018 BullForce Labs AB and contributors
+ */
+
+export type AttributeValue = string | number | boolean | Array;
+
+export interface SpanOptions {
+ kind?: number;
+ attributes?: Record;
+ parent?: unknown;
+}
+
+export interface TelemetrySpan {
+ setAttribute(key: string, value: AttributeValue): void;
+ setAttributes(attributes: Record): void;
+ addEvent(name: string, attributes?: Record): void;
+ recordException(exception: Error | string | { code?: number; message?: string; name?: string }): void;
+ setSpanOnContext(context: unknown): unknown;
+ end(): void;
+}
+
+export interface Tracer {
+ startSpan(name: string, options?: SpanOptions, context?: Context): TelemetrySpan;
+}
+
+export interface ContextManager {
+ active(): Context;
+ with unknown>(context: Context, fn: A): ReturnType ;
+ // Added in bullmq@6.3.11
+ root?(): Context;
+ getMetadata(context: Context): string;
+ fromMetadata(activeContext: Context, metadata: string): Context;
+}
+
+export interface MetricOptions {
+ description?: string;
+ unit?: string;
+}
+
+export interface Counter {
+ add(value: number, attributes?: Record): void;
+}
+
+export interface Histogram {
+ record(value: number, attributes?: Record): void;
+}
+
+export interface Gauge {
+ record(value: number, attributes?: Record): void;
+}
+
+export interface Meter {
+ createCounter(name: string, options?: MetricOptions): Counter;
+ createHistogram(name: string, options?: MetricOptions): Histogram;
+ createGauge?(name: string, options?: MetricOptions): Gauge;
+}
+
+export interface Telemetry {
+ tracer: Tracer;
+ contextManager: ContextManager;
+ meter?: Meter;
+}
+
+export interface SentryContext {
+ span: Span | undefined;
+ scope: Scope;
+ producerSpanContext?: {
+ traceId: string;
+ spanId: string;
+ sampled: boolean;
+ };
+}
diff --git a/packages/node/src/integrations/winston.ts b/packages/node/src/integrations/winston.ts
index fd0d510599d4..289d3463dab4 100644
--- a/packages/node/src/integrations/winston.ts
+++ b/packages/node/src/integrations/winston.ts
@@ -1,3 +1,4 @@
+import { SENTRY_ORIGIN } from '@sentry/conventions/attributes';
/* eslint-disable @typescript-eslint/ban-ts-comment */
import type { LogSeverityLevel } from '@sentry/core';
import { debug, isObjectLike } from '@sentry/core';
@@ -109,7 +110,7 @@ export function createSentryWinstonTransport {
attributes[`sentry.message.parameter.${index}`] = param;
});
diff --git a/packages/node/src/sdk/client.ts b/packages/node/src/sdk/client.ts
index e9dd30bf7bac..835a4dab387f 100644
--- a/packages/node/src/sdk/client.ts
+++ b/packages/node/src/sdk/client.ts
@@ -1,3 +1,4 @@
+import { SERVER_ADDRESS } from '@sentry/conventions/attributes';
import * as os from 'node:os';
import type { Tracer } from '@opentelemetry/api';
import { trace } from '@opentelemetry/api';
@@ -68,7 +69,7 @@ export class NodeClient extends ServerRuntimeClient {
this.on('beforeCaptureLog', log => {
log.attributes = {
...log.attributes,
- 'server.address': serverName,
+ [SERVER_ADDRESS]: serverName,
};
});
}
diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts
index 57cf3871256a..ccd4601bfc8b 100644
--- a/packages/node/src/sdk/index.ts
+++ b/packages/node/src/sdk/index.ts
@@ -20,6 +20,7 @@ import {
import { isMainThread, parentPort } from 'node:worker_threads';
import { detectOrchestrionSetup, getErrorIntegrations, getTracingIntegrations } from '@sentry/server-utils';
import { DEBUG_BUILD } from '../debug-build';
+import { awsLambdaWebIntegration } from '../integrations/awsLambdaWeb';
import { childProcessIntegration } from '../integrations/childProcess';
import { consoleIntegration } from '../integrations/console';
import { nodeContextIntegration } from '../integrations/context';
@@ -66,6 +67,7 @@ function getBaseDefaultIntegrations(): Integration[] {
// Event Info
contextLinesIntegration(),
localVariablesIntegration(),
+ awsLambdaWebIntegration(),
nodeContextIntegration(),
childProcessIntegration(),
workerThreadsIntegration(),
diff --git a/packages/node/test/cron.test.ts b/packages/node/test/cron.test.ts
index bf1e83d315e5..f5178dc98e88 100644
--- a/packages/node/test/cron.test.ts
+++ b/packages/node/test/cron.test.ts
@@ -220,5 +220,14 @@ describe('cron check-ins', () => {
"Automatic instrumentation of 'node-schedule' requires the first parameter of 'scheduleJob' to be a job name string and the second parameter to be a crontab string",
);
});
+
+ test('marks the callback as monitored, so nodeScheduleIntegration skips it', () => {
+ const scheduleWithCheckIn = cron.instrumentNodeSchedule({
+ scheduleJob: (_name: unknown, _expression: unknown, callback: unknown) => callback,
+ });
+
+ const callback = scheduleWithCheckIn.scheduleJob('my-cron-job', '* * * * *', () => {}) as Record;
+ expect(callback[Symbol.for('sentry.cron.monitored')]).toBe(true);
+ });
});
});
diff --git a/packages/node/test/integrations/awsLambdaWeb.test.ts b/packages/node/test/integrations/awsLambdaWeb.test.ts
new file mode 100644
index 000000000000..cd3b30c1b716
--- /dev/null
+++ b/packages/node/test/integrations/awsLambdaWeb.test.ts
@@ -0,0 +1,181 @@
+import {
+ AWS_LAMBDA_INITIALIZATION_TYPE,
+ AWS_LAMBDA_WEB_ENDPOINT_NAME,
+ CLOUD_REGION,
+ FAAS_COLDSTART,
+ FAAS_INVOCATION_ID,
+ FAAS_MAX_MEMORY,
+ SENTRY_ORIGIN,
+} from '@sentry/conventions/attributes';
+import type { Integration } from '@sentry/core';
+import { getCurrentScope, getIsolationScope, SentrySpan, spanToJSON, withIsolationScope } from '@sentry/core';
+import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { NodeClient } from '../../src/sdk/client';
+
+describe('awsLambdaWebIntegration', () => {
+ let integration: Integration;
+ let client: NodeClient;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ setAsyncLocalStorageAsyncContextStrategy();
+ vi.stubEnv('AWS_LAMBDA_HTTP_ENDPOINT', '0.0.0.0:3000');
+ vi.stubEnv('AWS_REGION', 'us-east-1');
+ vi.stubEnv('AWS_LAMBDA_FUNCTION_MEMORY_SIZE', '2048');
+ vi.stubEnv('AWS_LAMBDA_INITIALIZATION_TYPE', 'native-http');
+ const { awsLambdaWebIntegration } = await import('../../src/integrations/awsLambdaWeb');
+ integration = awsLambdaWebIntegration();
+ client = new NodeClient({
+ dsn: 'https://public@example.com/1',
+ integrations: [],
+ stackParser: () => [],
+ transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }),
+ });
+ getCurrentScope().setClient(client);
+ });
+
+ afterEach(async () => {
+ client.dispose();
+ vi.restoreAllMocks();
+ await client.close();
+ getCurrentScope().setClient(undefined);
+ getIsolationScope().setContext('aws.lambda', null);
+ vi.unstubAllEnvs();
+ });
+
+ it('is inert outside Web Functions', async () => {
+ vi.stubEnv('AWS_LAMBDA_HTTP_ENDPOINT', undefined);
+ const { awsLambdaWebIntegration } = await import('../../src/integrations/awsLambdaWeb');
+ const disabled = awsLambdaWebIntegration();
+
+ expect(disabled).toEqual({ name: 'AwsLambdaWeb' });
+ });
+
+ it('preserves request and user context while adding environment metadata', () => {
+ const event = {
+ contexts: { 'aws.lambda': { aws_request_id: 'request' }, cloud_resource: { [CLOUD_REGION]: 'custom' } },
+ };
+ expect(integration.processEvent!(event, {}, client)).toEqual({
+ contexts: {
+ 'aws.lambda': { aws_request_id: 'request', memory_size: 2_147_483_648, initialization_type: 'native-http' },
+ cloud_resource: { [CLOUD_REGION]: 'custom' },
+ },
+ });
+ });
+
+ it('preserves existing Lambda context while replacing request-specific values', () => {
+ integration.setup!(client);
+
+ withIsolationScope(scope => {
+ scope.setContext('aws.lambda', {
+ function_name: 'checkout',
+ revision_id: 'rev-current',
+ aws_request_id: 'previous-request',
+ endpoint_name: 'previous-endpoint',
+ cold_start: false,
+ });
+ client.emit(
+ 'httpServerRequest',
+ {},
+ {},
+ { headers: { 'lambda-web-request-id': 'current-request', 'lambda-web-endpoint-name': 'prod' } },
+ );
+
+ expect(scope.getScopeData().contexts['aws.lambda']).toEqual({
+ function_name: 'checkout',
+ revision_id: 'rev-current',
+ aws_request_id: 'current-request',
+ endpoint_name: 'prod',
+ cold_start: true,
+ });
+ });
+ });
+
+ it('preserves explicit root span attributes while filling missing metadata', () => {
+ integration.setup!(client);
+
+ withIsolationScope(() => {
+ client.emit('httpServerRequest', {}, {}, { headers: { 'lambda-web-request-id': 'request' } });
+ const span = new SentrySpan({
+ name: 'request',
+ attributes: { [CLOUD_REGION]: 'custom-region', [FAAS_COLDSTART]: false, [FAAS_MAX_MEMORY]: 0 },
+ });
+ const spanJSON = spanToJSON(span);
+ integration.processSegmentSpan!(spanJSON, client);
+
+ expect(spanJSON.attributes).toEqual({
+ [SENTRY_ORIGIN]: 'manual',
+ [CLOUD_REGION]: 'custom-region',
+ [FAAS_COLDSTART]: false,
+ [FAAS_INVOCATION_ID]: 'request',
+ [FAAS_MAX_MEMORY]: 0,
+ [AWS_LAMBDA_INITIALIZATION_TYPE]: 'native-http',
+ });
+ });
+ });
+
+ it('clears inherited request identifiers when headers are absent', () => {
+ integration.setup!(client);
+
+ withIsolationScope(scope => {
+ scope.setContext('aws.lambda', { revision_id: 'rev-current', aws_request_id: 'old', endpoint_name: 'old' });
+ client.emit('httpServerRequest', {}, {}, { headers: {} });
+ const span = new SentrySpan({ name: 'request' });
+ const spanJSON = spanToJSON(span);
+ integration.processSegmentSpan!(spanJSON, client);
+
+ expect(scope.getScopeData().contexts['aws.lambda']).toEqual({ revision_id: 'rev-current', cold_start: true });
+ expect(spanJSON.attributes).not.toHaveProperty([FAAS_INVOCATION_ID]);
+ expect(spanJSON.attributes).not.toHaveProperty([AWS_LAMBDA_WEB_ENDPOINT_NAME]);
+ });
+ });
+
+ it('adds static metadata to segment spans outside requests without overwriting attributes', () => {
+ const span = spanToJSON(new SentrySpan({ name: 'startup', attributes: { [CLOUD_REGION]: 'custom-region' } }));
+
+ integration.processSegmentSpan!(span, client);
+
+ expect(span.attributes).toEqual({
+ [SENTRY_ORIGIN]: 'manual',
+ [CLOUD_REGION]: 'custom-region',
+ [FAAS_MAX_MEMORY]: 2_147_483_648,
+ [AWS_LAMBDA_INITIALIZATION_TYPE]: 'native-http',
+ });
+ });
+
+ it.each([undefined, '', 'invalid', '0', '-1', 'Infinity', '1e308'])(
+ 'omits invalid memory size %s from spans and error contexts',
+ async memory => {
+ vi.stubEnv('AWS_LAMBDA_FUNCTION_MEMORY_SIZE', memory);
+ const { awsLambdaWebIntegration } = await import('../../src/integrations/awsLambdaWeb');
+ const integration = awsLambdaWebIntegration();
+ const span = spanToJSON(new SentrySpan({ name: 'startup' }));
+ const event = { contexts: { 'aws.lambda': {} } };
+
+ integration.processSegmentSpan!(span, client);
+ integration.processEvent!(event, {}, client);
+
+ expect(span.attributes).not.toHaveProperty([FAAS_MAX_MEMORY]);
+ expect(event.contexts['aws.lambda']).toHaveProperty('memory_size', undefined);
+ },
+ );
+
+ it('omits unavailable metadata and does not infer function identity', async () => {
+ vi.stubEnv('AWS_REGION', undefined);
+ vi.stubEnv('AWS_LAMBDA_FUNCTION_MEMORY_SIZE', 'invalid');
+ vi.stubEnv('AWS_LAMBDA_INITIALIZATION_TYPE', undefined);
+ const { awsLambdaWebIntegration } = await import('../../src/integrations/awsLambdaWeb');
+ const minimal = awsLambdaWebIntegration();
+ minimal.setup!(client);
+ withIsolationScope(scope => {
+ client.emit('httpServerRequest', {}, {}, { headers: {} });
+ expect(scope.getScopeData().contexts['aws.lambda']).toEqual({ cold_start: true });
+ });
+ expect(minimal.processEvent!({}, {}, client)).toEqual({
+ contexts: {
+ 'aws.lambda': {},
+ },
+ });
+ });
+});
diff --git a/packages/node/test/integrations/tracing/bullmq.test.ts b/packages/node/test/integrations/tracing/bullmq.test.ts
new file mode 100644
index 000000000000..a4fec420645f
--- /dev/null
+++ b/packages/node/test/integrations/tracing/bullmq.test.ts
@@ -0,0 +1,688 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import * as SentryCore from '@sentry/core';
+import { BullMQTelemetry } from '../../../src/integrations/tracing/bullmq';
+
+vi.mock('@sentry/core', async importOriginal => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ startInactiveSpan: vi.fn(() => mockOtelSpan()),
+ getActiveSpan: vi.fn(() => undefined),
+ getCurrentScope: vi.fn(() => mockScope()),
+ withActiveSpan: vi.fn((_span, fn) => fn()),
+ startNewTrace: vi.fn(fn => fn()),
+ withIsolationScope: vi.fn(fn => fn()),
+ spanToTraceHeader: vi.fn(() => '00-traceid-spanid-01'),
+ captureException: vi.fn(),
+ metrics: {
+ count: vi.fn(),
+ distribution: vi.fn(),
+ gauge: vi.fn(),
+ },
+ };
+});
+
+function mockOtelSpan() {
+ return {
+ setAttribute: vi.fn(),
+ setAttributes: vi.fn(),
+ addEvent: vi.fn(),
+ addLink: vi.fn(),
+ recordException: vi.fn(),
+ setStatus: vi.fn(),
+ end: vi.fn(),
+ spanContext: () => ({ spanId: 'abc123', traceId: 'def456', traceFlags: 1 }),
+ };
+}
+
+function mockScope() {
+ return {
+ setTag: vi.fn(),
+ setContext: vi.fn(),
+ };
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('BullMQTelemetry', () => {
+ it('does not create a meter by default', () => {
+ const telemetry = new BullMQTelemetry();
+
+ expect(telemetry.meter).toBeUndefined();
+ });
+
+ it('creates a meter when enableMetrics is true', () => {
+ const telemetry = new BullMQTelemetry({ enableMetrics: true });
+
+ expect(telemetry.meter).toBeDefined();
+ });
+});
+
+describe('SentryBullMQTracer', () => {
+ describe('startSpan', () => {
+ it.each([
+ { name: 'add myQueue', expectedOp: 'queue.publish', expectedOrigin: 'auto.queue.bullmq.producer' },
+ { name: 'addBulk myQueue', expectedOp: 'queue.publish', expectedOrigin: 'auto.queue.bullmq.producer' },
+ { name: 'addFlow myQueue', expectedOp: 'queue.publish', expectedOrigin: 'auto.queue.bullmq.producer' },
+ { name: 'addBulkFlows myQueue', expectedOp: 'queue.publish', expectedOrigin: 'auto.queue.bullmq.producer' },
+ { name: 'pause myQueue', expectedOp: 'queue', expectedOrigin: 'auto.queue.bullmq' },
+ { name: 'close myQueue', expectedOp: 'queue', expectedOrigin: 'auto.queue.bullmq' },
+ { name: 'drain myQueue', expectedOp: 'queue', expectedOrigin: 'auto.queue.bullmq' },
+ { name: 'getNextJob myQueue', expectedOp: 'queue', expectedOrigin: 'auto.queue.bullmq' },
+ { name: 'moveStalledJobsToWait myQueue', expectedOp: 'queue', expectedOrigin: 'auto.queue.bullmq' },
+ ])('maps "$name" to op=$expectedOp and origin=$expectedOrigin', ({ name, expectedOp, expectedOrigin }) => {
+ const telemetry = new BullMQTelemetry();
+
+ telemetry.tracer.startSpan(name);
+
+ expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith({
+ name,
+ attributes: {
+ 'sentry.op': expectedOp,
+ 'sentry.origin': expectedOrigin,
+ 'messaging.system': 'bullmq',
+ },
+ onlyIfParent: expectedOp === 'queue',
+ });
+ expect(SentryCore.startNewTrace).not.toHaveBeenCalled();
+ });
+
+ it('maps "process myQueue" to op=queue.process and origin=auto.queue.bullmq.consumer', () => {
+ const telemetry = new BullMQTelemetry();
+
+ telemetry.tracer.startSpan('process myQueue');
+
+ expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith({
+ name: 'process myQueue',
+ attributes: {
+ 'sentry.op': 'queue.process',
+ 'sentry.origin': 'auto.queue.bullmq.consumer',
+ 'messaging.system': 'bullmq',
+ },
+ });
+ });
+
+ it('merges attributes from SpanOptions and gives them messaging keys', () => {
+ const telemetry = new BullMQTelemetry();
+
+ telemetry.tracer.startSpan('add emails', {
+ attributes: { 'bullmq.queue.name': 'emails', 'bullmq.job.name': 'welcome-email' },
+ });
+
+ expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith({
+ name: 'add emails',
+ attributes: {
+ 'sentry.op': 'queue.publish',
+ 'sentry.origin': 'auto.queue.bullmq.producer',
+ 'messaging.system': 'bullmq',
+ 'messaging.destination.name': 'emails',
+ 'messaging.bullmq.job.name': 'welcome-email',
+ },
+ onlyIfParent: false,
+ });
+ });
+
+ it('starts each queue.process span in a new trace', () => {
+ const telemetry = new BullMQTelemetry();
+
+ telemetry.tracer.startSpan('process notifications');
+
+ expect(SentryCore.startNewTrace).toHaveBeenCalledOnce();
+ });
+
+ it('adds span link to producer when context has producerSpanContext', () => {
+ const telemetry = new BullMQTelemetry();
+ const otelSpan = mockOtelSpan();
+ vi.mocked(SentryCore.startInactiveSpan).mockReturnValueOnce(otelSpan as any);
+
+ telemetry.tracer.startSpan('process myQueue', undefined, {
+ span: undefined,
+ scope: mockScope() as any,
+ producerSpanContext: {
+ traceId: 'aabbccddaabbccddaabbccddaabbccdd',
+ spanId: '1122334455667788',
+ sampled: true,
+ },
+ });
+
+ expect(otelSpan.addLink).toHaveBeenCalledWith({
+ context: {
+ traceId: 'aabbccddaabbccddaabbccddaabbccdd',
+ spanId: '1122334455667788',
+ traceFlags: 1,
+ },
+ attributes: {
+ 'sentry.link.type': 'message_origin',
+ },
+ });
+ });
+
+ it('adds the span link with traceFlags 0 when the producer span was not sampled', () => {
+ const telemetry = new BullMQTelemetry();
+ const otelSpan = mockOtelSpan();
+ vi.mocked(SentryCore.startInactiveSpan).mockReturnValueOnce(otelSpan as any);
+
+ telemetry.tracer.startSpan('process myQueue', undefined, {
+ span: undefined,
+ scope: mockScope() as any,
+ producerSpanContext: {
+ traceId: 'aabbccddaabbccddaabbccddaabbccdd',
+ spanId: '1122334455667788',
+ sampled: false,
+ },
+ });
+
+ expect(otelSpan.addLink).toHaveBeenCalledWith({
+ context: {
+ traceId: 'aabbccddaabbccddaabbccddaabbccdd',
+ spanId: '1122334455667788',
+ traceFlags: 0,
+ },
+ attributes: {
+ 'sentry.link.type': 'message_origin',
+ },
+ });
+ });
+
+ it('does not add span link when context has no producerSpanContext', () => {
+ const telemetry = new BullMQTelemetry();
+ const otelSpan = mockOtelSpan();
+ vi.mocked(SentryCore.startInactiveSpan).mockReturnValueOnce(otelSpan as any);
+
+ telemetry.tracer.startSpan('process myQueue', undefined, {
+ span: undefined,
+ scope: mockScope() as any,
+ });
+
+ expect(otelSpan.addLink).not.toHaveBeenCalled();
+ });
+ });
+});
+
+describe('SentryBullMQSpan', () => {
+ function createSpan() {
+ const telemetry = new BullMQTelemetry();
+ const otelSpan = mockOtelSpan();
+ const scope = mockScope();
+ vi.mocked(SentryCore.startInactiveSpan).mockReturnValueOnce(otelSpan as any);
+ vi.mocked(SentryCore.getCurrentScope).mockReturnValueOnce(scope as any);
+ const span = telemetry.tracer.startSpan('process emails');
+ return { span, otelSpan, scope };
+ }
+
+ it('replaces the BullMQ attributes that have a messaging convention and prefixes the others', () => {
+ const { span, otelSpan } = createSpan();
+
+ span.setAttributes({
+ 'bullmq.queue.name': 'emails',
+ 'bullmq.queue.operation': 'process',
+ 'bullmq.job.id': '42',
+ 'bullmq.job.attempts.made': 3,
+ 'bullmq.job.bulk.count': 5,
+ 'bullmq.worker.id': '03327d57-05cc-4528-9d24-c59bf6520039',
+ 'bullmq.job.name': 'welcome-email',
+ 'bullmq.worker.name': 'mailer',
+ });
+
+ expect(otelSpan.setAttributes).toHaveBeenCalledWith({
+ 'messaging.bullmq.job.name': 'welcome-email',
+ 'messaging.bullmq.worker.name': 'mailer',
+ 'messaging.destination.name': 'emails',
+ 'messaging.operation.name': 'process',
+ 'messaging.message.id': '42',
+ 'messaging.message.retry.count': 2,
+ 'messaging.batch.message_count': 5,
+ 'messaging.client.id': '03327d57-05cc-4528-9d24-c59bf6520039',
+ });
+ });
+
+ it('replaces the queue name when BullMQ sets it with setAttribute', () => {
+ const { span, otelSpan } = createSpan();
+
+ span.setAttribute('bullmq.queue.name', 'emails');
+
+ expect(otelSpan.setAttributes).toHaveBeenCalledWith({
+ 'messaging.destination.name': 'emails',
+ });
+ });
+
+ it('sets a retry count of 0 for the first attempt', () => {
+ const { span, otelSpan } = createSpan();
+
+ span.setAttributes({ 'bullmq.job.attempts.made': 1 });
+
+ expect(otelSpan.setAttributes).toHaveBeenCalledWith({
+ 'messaging.message.retry.count': 0,
+ });
+ });
+
+ it('leaves out the BullMQ attributes that hold JSON strings', () => {
+ const { span, otelSpan } = createSpan();
+
+ span.setAttributes({
+ 'bullmq.job.id': '42',
+ 'bullmq.job.options': '{"removeChildren":true}',
+ 'bullmq.job.progress': '{"percentage":50}',
+ 'bullmq.queue.options': '{"count":10}',
+ 'bullmq.worker.options': '{"block":false}',
+ });
+
+ expect(otelSpan.setAttributes).toHaveBeenCalledWith({
+ 'messaging.message.id': '42',
+ });
+ });
+
+ it('uses snake_case for the words of multi-word BullMQ keys', () => {
+ const { span, otelSpan } = createSpan();
+
+ span.setAttributes({
+ 'bullmq.job.processed.timestamp': 1,
+ 'bullmq.queue.event.max.length': 2,
+ 'bullmq.queue.rate.limit': 3,
+ 'bullmq.worker.do.not.wait.active': 4,
+ 'bullmq.worker.force.close': 5,
+ 'bullmq.worker.jobs.to.extend.locks': 6,
+ 'bullmq.worker.rate.limit': 7,
+ 'bullmq.worker.stalled.jobs': 8,
+ });
+
+ expect(otelSpan.setAttributes).toHaveBeenCalledWith({
+ 'messaging.bullmq.job.processed_timestamp': 1,
+ 'messaging.bullmq.queue.event.max_length': 2,
+ 'messaging.bullmq.queue.rate_limit': 3,
+ 'messaging.bullmq.worker.do_not_wait_active': 4,
+ 'messaging.bullmq.worker.force_close': 5,
+ 'messaging.bullmq.worker.jobs_to_extend_locks': 6,
+ 'messaging.bullmq.worker.rate_limit': 7,
+ 'messaging.bullmq.worker.stalled_jobs': 8,
+ });
+ });
+
+ it('leaves out the deprecated bullmq.job.finished.timestamp', () => {
+ const { span, otelSpan } = createSpan();
+
+ span.setAttributes({
+ 'bullmq.job.finished.timestamp': 1791184722000,
+ 'bullmq.job.attempt_finished_timestamp': 1791184722000,
+ });
+
+ expect(otelSpan.setAttributes).toHaveBeenCalledWith({
+ 'messaging.bullmq.job.attempt_finished_timestamp': 1791184722000,
+ });
+ });
+
+ it('sends bullmq.job.status as messaging.bullmq.job.state', () => {
+ const { span, otelSpan } = createSpan();
+
+ span.setAttributes({ 'bullmq.job.status': 'completed' });
+
+ expect(otelSpan.setAttributes).toHaveBeenCalledWith({
+ 'messaging.bullmq.job.state': 'completed',
+ });
+ });
+
+ it('prefers bullmq.job.state when BullMQ sets it together with bullmq.job.status', () => {
+ const { span, otelSpan } = createSpan();
+
+ span.setAttributes({ 'bullmq.job.state': 'failed', 'bullmq.job.status': 'completed' });
+
+ expect(otelSpan.setAttributes).toHaveBeenCalledWith({
+ 'messaging.bullmq.job.state': 'failed',
+ });
+ });
+
+ it('passes events to the OTel span without renaming their attributes', () => {
+ const { span, otelSpan } = createSpan();
+
+ span.addEvent('job completed', { 'bullmq.job.result': '{"sent":true}' });
+
+ expect(otelSpan.addEvent).toHaveBeenCalledWith('job completed', { 'bullmq.job.result': '{"sent":true}' });
+ });
+
+ describe('recordException', () => {
+ it('records Error instances on the OTel span without capturing them', () => {
+ const { span, otelSpan } = createSpan();
+ const error = new Error('Redis connection refused');
+
+ span.recordException(error);
+
+ expect(otelSpan.recordException).toHaveBeenCalledWith(error);
+ expect(SentryCore.captureException).not.toHaveBeenCalled();
+ });
+
+ it('wraps non-Error exception objects in an Error', () => {
+ const { span, otelSpan } = createSpan();
+
+ span.recordException({ code: 503, message: 'Service unavailable' });
+
+ expect(otelSpan.recordException).toHaveBeenCalledWith(new Error('Service unavailable'));
+ expect(SentryCore.captureException).not.toHaveBeenCalled();
+ });
+
+ it('wraps string exceptions in an Error', () => {
+ const { span, otelSpan } = createSpan();
+
+ (span as any).recordException('Connection timed out');
+
+ expect(otelSpan.recordException).toHaveBeenCalledWith(new Error('Connection timed out'));
+ expect(SentryCore.captureException).not.toHaveBeenCalled();
+ });
+
+ it('uses fallback message when non-Error exception has no message', () => {
+ const { span, otelSpan } = createSpan();
+
+ span.recordException({ code: 500 });
+
+ expect(otelSpan.recordException).toHaveBeenCalledWith(new Error('Unknown error'));
+ expect(SentryCore.captureException).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('addEvent', () => {
+ it('captures exception when event name is "job failed"', () => {
+ const { span } = createSpan();
+
+ span.addEvent('job failed', { 'bullmq.job.failed.reason': 'Connection refused' });
+
+ expect(SentryCore.captureException).toHaveBeenCalledWith(new Error('Connection refused'), {
+ mechanism: { handled: false, type: 'auto.queue.bullmq' },
+ });
+ });
+
+ it('sets the error status on the span when the event name is "job failed"', () => {
+ const { span, otelSpan } = createSpan();
+
+ span.addEvent('job failed', { 'bullmq.job.failed.reason': 'Connection refused' });
+
+ expect(otelSpan.setStatus).toHaveBeenCalledWith({
+ code: SentryCore.SPAN_STATUS_ERROR,
+ message: 'internal_error',
+ });
+ });
+
+ it('does not capture an exception or set an error status for other event names', () => {
+ const { span, otelSpan } = createSpan();
+
+ span.addEvent('job completed', { 'bullmq.job.result': '{"ok":true}' });
+
+ expect(SentryCore.captureException).not.toHaveBeenCalled();
+ expect(otelSpan.setStatus).not.toHaveBeenCalled();
+ });
+ });
+
+ it('returns context with span and scope from setSpanOnContext', () => {
+ const { span, otelSpan, scope } = createSpan();
+ const inputContext = { existingKey: 'value' };
+
+ const result = span.setSpanOnContext(inputContext);
+
+ expect(result).toEqual({ existingKey: 'value', span: otelSpan, scope });
+ });
+
+ it('delegates end to the underlying OTel span', () => {
+ const { span, otelSpan } = createSpan();
+
+ span.end();
+
+ expect(otelSpan.end).toHaveBeenCalledOnce();
+ });
+});
+
+describe('SentryBullMQContextManager', () => {
+ it('returns current active span and scope from active()', () => {
+ const telemetry = new BullMQTelemetry();
+ const activeSpan = mockOtelSpan();
+ const scope = mockScope();
+ vi.mocked(SentryCore.getActiveSpan).mockReturnValueOnce(activeSpan as any);
+ vi.mocked(SentryCore.getCurrentScope).mockReturnValueOnce(scope as any);
+
+ const context = telemetry.contextManager.active();
+
+ expect(context).toEqual({ span: activeSpan, scope });
+ });
+
+ describe('with', () => {
+ it('calls withActiveSpan inside withIsolationScope when context has a span', () => {
+ const telemetry = new BullMQTelemetry();
+ const fakeSpan = mockOtelSpan();
+ const context = { span: fakeSpan, scope: mockScope() };
+ const fn = vi.fn(() => 'result');
+
+ const result = telemetry.contextManager.with(context as any, fn);
+
+ expect(result).toBe('result');
+ expect(SentryCore.withIsolationScope).toHaveBeenCalledOnce();
+ expect(SentryCore.withActiveSpan).toHaveBeenCalledWith(fakeSpan, fn);
+ });
+
+ it('clears the active span inside withIsolationScope when context has no span', () => {
+ const telemetry = new BullMQTelemetry();
+ const context = { span: undefined, scope: mockScope() };
+ const fn = vi.fn(() => 'result');
+
+ const result = telemetry.contextManager.with(context as any, fn);
+
+ expect(result).toBe('result');
+ expect(SentryCore.withIsolationScope).toHaveBeenCalledOnce();
+ expect(SentryCore.withActiveSpan).toHaveBeenCalledWith(null, fn);
+ });
+ });
+
+ describe('root', () => {
+ it('returns a context without a span that runs callbacks without an active span', () => {
+ const telemetry = new BullMQTelemetry();
+ const fn = vi.fn(() => 'result');
+
+ const context = telemetry.contextManager.root!();
+ const result = telemetry.contextManager.with(context, fn);
+
+ expect(context.span).toBeUndefined();
+ expect(result).toBe('result');
+ expect(SentryCore.withActiveSpan).toHaveBeenCalledWith(null, fn);
+ });
+ });
+
+ describe('getMetadata', () => {
+ it('returns sentry-trace header when context has a span', () => {
+ const telemetry = new BullMQTelemetry();
+ const fakeSpan = mockOtelSpan();
+ const context = { span: fakeSpan, scope: mockScope() };
+
+ const metadata = telemetry.contextManager.getMetadata(context as any);
+
+ expect(SentryCore.spanToTraceHeader).toHaveBeenCalledWith(fakeSpan);
+ expect(metadata).toBe('00-traceid-spanid-01');
+ });
+
+ it('returns empty string when context has no span', () => {
+ const telemetry = new BullMQTelemetry();
+ const context = { span: undefined, scope: mockScope() };
+
+ const metadata = telemetry.contextManager.getMetadata(context as any);
+
+ expect(metadata).toBe('');
+ expect(SentryCore.spanToTraceHeader).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('fromMetadata', () => {
+ it('parses sentry-trace header and attaches producerSpanContext', () => {
+ const telemetry = new BullMQTelemetry();
+ const fakeSpan = mockOtelSpan();
+ const context = { span: fakeSpan, scope: mockScope() };
+
+ const result = telemetry.contextManager.fromMetadata(
+ context as any,
+ 'aabbccddaabbccddaabbccddaabbccdd-1122334455667788-1',
+ );
+
+ expect(result).toEqual({
+ span: fakeSpan,
+ scope: context.scope,
+ producerSpanContext: {
+ traceId: 'aabbccddaabbccddaabbccddaabbccdd',
+ spanId: '1122334455667788',
+ sampled: true,
+ },
+ });
+ });
+
+ it('marks the producer span context as not sampled when the sentry-trace header has no sampled flag', () => {
+ const telemetry = new BullMQTelemetry();
+ const context = { span: undefined, scope: mockScope() };
+
+ const result = telemetry.contextManager.fromMetadata(
+ context as any,
+ 'aabbccddaabbccddaabbccddaabbccdd-1122334455667788',
+ );
+
+ expect(result.producerSpanContext).toEqual({
+ traceId: 'aabbccddaabbccddaabbccddaabbccdd',
+ spanId: '1122334455667788',
+ sampled: false,
+ });
+ });
+
+ it('returns the active context unchanged when metadata is empty', () => {
+ const telemetry = new BullMQTelemetry();
+ const context = { span: undefined, scope: mockScope() };
+
+ const result = telemetry.contextManager.fromMetadata(context as any, '');
+
+ expect(result).toBe(context);
+ });
+
+ it('returns the active context unchanged when metadata is invalid', () => {
+ const telemetry = new BullMQTelemetry();
+ const context = { span: undefined, scope: mockScope() };
+
+ const result = telemetry.contextManager.fromMetadata(context as any, 'not-a-valid-header');
+
+ expect(result).toBe(context);
+ });
+ });
+});
+
+describe('SentryBullMQMeter', () => {
+ describe('counter', () => {
+ it('delegates add to metrics.count with name and unit', () => {
+ const telemetry = new BullMQTelemetry({ enableMetrics: true });
+ const counter = telemetry.meter!.createCounter('bullmq.jobs.completed', { unit: '1' });
+
+ counter.add(5, { 'queue.name': 'emails' });
+
+ expect(SentryCore.metrics.count).toHaveBeenCalledWith('messaging.bullmq.jobs.completed', 5, {
+ unit: 'none',
+ attributes: { 'queue.name': 'emails' },
+ });
+ });
+
+ it('passes undefined attributes when none provided', () => {
+ const telemetry = new BullMQTelemetry({ enableMetrics: true });
+ const counter = telemetry.meter!.createCounter('bullmq.jobs.failed');
+
+ counter.add(1);
+
+ expect(SentryCore.metrics.count).toHaveBeenCalledWith('messaging.bullmq.jobs.failed', 1, {
+ unit: undefined,
+ attributes: undefined,
+ });
+ });
+ });
+
+ describe('histogram', () => {
+ it('records bullmq.job.duration as messaging.process.duration in seconds', () => {
+ const telemetry = new BullMQTelemetry({ enableMetrics: true });
+ const histogram = telemetry.meter!.createHistogram('bullmq.job.duration', { unit: 'ms' });
+
+ histogram.record(1500, { 'bullmq.queue.name': 'notifications' });
+
+ expect(SentryCore.metrics.distribution).toHaveBeenCalledWith('messaging.process.duration', 1.5, {
+ unit: 'second',
+ attributes: {
+ 'messaging.destination.name': 'notifications',
+ 'messaging.system': 'bullmq',
+ 'messaging.operation.name': 'process',
+ },
+ });
+ });
+
+ it('keeps a unit that is already a Sentry unit', () => {
+ const telemetry = new BullMQTelemetry({ enableMetrics: true });
+ const histogram = telemetry.meter!.createHistogram('bullmq.job.payload', { unit: 'byte' });
+
+ histogram.record(512);
+
+ expect(SentryCore.metrics.distribution).toHaveBeenCalledWith('messaging.bullmq.job.payload', 512, {
+ unit: 'byte',
+ attributes: undefined,
+ });
+ });
+ });
+
+ describe('gauge', () => {
+ it('delegates record to metrics.gauge', () => {
+ const telemetry = new BullMQTelemetry({ enableMetrics: true });
+ const gauge = telemetry.meter!.createGauge!('bullmq.queue.jobs', { unit: '{jobs}' });
+
+ gauge.record(37, { 'queue.name': 'reports' });
+
+ expect(SentryCore.metrics.gauge).toHaveBeenCalledWith('messaging.bullmq.queue.jobs', 37, {
+ unit: 'none',
+ attributes: { 'queue.name': 'reports' },
+ });
+ });
+ });
+
+ describe('attribute filtering', () => {
+ it('replaces the BullMQ queue name with the messaging destination', () => {
+ const telemetry = new BullMQTelemetry({ enableMetrics: true });
+ const counter = telemetry.meter!.createCounter('bullmq.jobs.completed');
+
+ counter.add(1, { 'bullmq.queue.name': 'emails', 'bullmq.job.name': 'welcome-email' });
+
+ expect(SentryCore.metrics.count).toHaveBeenCalledWith('messaging.bullmq.jobs.completed', 1, {
+ unit: undefined,
+ attributes: {
+ 'messaging.bullmq.job.name': 'welcome-email',
+ 'messaging.destination.name': 'emails',
+ },
+ });
+ });
+
+ it('filters out array attribute values', () => {
+ const telemetry = new BullMQTelemetry({ enableMetrics: true });
+ const counter = telemetry.meter!.createCounter('bullmq.jobs.completed');
+
+ counter.add(1, {
+ 'queue.name': 'emails',
+ tags: ['urgent', 'retry'],
+ priority: 5,
+ enabled: true,
+ });
+
+ expect(SentryCore.metrics.count).toHaveBeenCalledWith('messaging.bullmq.jobs.completed', 1, {
+ unit: undefined,
+ attributes: {
+ 'queue.name': 'emails',
+ priority: 5,
+ enabled: true,
+ },
+ });
+ });
+
+ it('returns empty object when all attributes are arrays', () => {
+ const telemetry = new BullMQTelemetry({ enableMetrics: true });
+ const counter = telemetry.meter!.createCounter('bullmq.jobs.completed');
+
+ counter.add(1, { tags: ['a', 'b'] });
+
+ expect(SentryCore.metrics.count).toHaveBeenCalledWith('messaging.bullmq.jobs.completed', 1, {
+ unit: undefined,
+ attributes: {},
+ });
+ });
+ });
+});
diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json
index 188bedef5b6d..c9405d0fb6e5 100644
--- a/packages/nuxt/package.json
+++ b/packages/nuxt/package.json
@@ -56,7 +56,7 @@
"@nuxt/kit": "^4.6.0",
"@sentry/browser": "11.10.0",
"@sentry/cloudflare": "11.10.0",
- "@sentry/conventions": "^0.29.0",
+ "@sentry/conventions": "^0.30.0",
"@sentry/core": "11.10.0",
"@sentry/node": "11.10.0",
"@sentry/bundler-plugins": "11.10.0",
@@ -76,7 +76,7 @@
"nuxi": "^3.25.1",
"nuxt": "3.21.11",
"unstorage": "^1.17.5",
- "vite": "^8.3.1",
+ "vite": "^8.3.5",
"vue": "^3.5.41"
},
"scripts": {
diff --git a/packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts b/packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts
index 270654bb9ff8..1786be5d69c1 100644
--- a/packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts
+++ b/packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts
@@ -1,4 +1,10 @@
-import { SENTRY_SEGMENT_NAME_SOURCE, SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes';
+import {
+ SENTRY_SEGMENT_NAME_SOURCE,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
+ HTTP_REQUEST_METHOD,
+ HTTP_ROUTE,
+} from '@sentry/conventions/attributes';
import { MIDDLEWARE } from '@sentry/conventions/op';
import {
captureException,
@@ -183,11 +189,11 @@ function getSpanAttributes(
const path = event.path ?? eventH3v2?.url?.pathname;
if (method) {
- attributes['http.request.method'] = method;
+ attributes[HTTP_REQUEST_METHOD] = method;
}
if (path) {
- attributes['http.route'] = path;
+ attributes[HTTP_ROUTE] = path;
}
// h3 v1 (Nuxt 4): headers are on event.node.req.headers
diff --git a/packages/nuxt/src/runtime/plugins/storage-legacy.server.ts b/packages/nuxt/src/runtime/plugins/storage-legacy.server.ts
index 77b6a390d0e9..ac445ad0c432 100644
--- a/packages/nuxt/src/runtime/plugins/storage-legacy.server.ts
+++ b/packages/nuxt/src/runtime/plugins/storage-legacy.server.ts
@@ -7,6 +7,6 @@ import { createStoragePlugin } from '../utils/instrumentStorage';
/**
* Nitro plugin that instruments storage driver calls for Nuxt v3/v4 (Nitro v2)
*/
-export default (async _nitroApp => {
- await createStoragePlugin(useStorage, userStorageMounts as string[]);
+export default (_nitroApp => {
+ createStoragePlugin(useStorage, userStorageMounts as string[]);
}) satisfies NitroAppPlugin;
diff --git a/packages/nuxt/src/runtime/plugins/storage.server.ts b/packages/nuxt/src/runtime/plugins/storage.server.ts
index f9763b51d47b..726857f037dd 100644
--- a/packages/nuxt/src/runtime/plugins/storage.server.ts
+++ b/packages/nuxt/src/runtime/plugins/storage.server.ts
@@ -7,6 +7,6 @@ import { createStoragePlugin } from '../utils/instrumentStorage';
/**
* Nitro plugin that instruments storage driver calls for Nuxt v5+ (Nitro v3+)
*/
-export default (async _nitroApp => {
- await createStoragePlugin(useStorage, userStorageMounts as string[]);
+export default (_nitroApp => {
+ createStoragePlugin(useStorage, userStorageMounts as string[]);
}) satisfies NitroAppPlugin;
diff --git a/packages/nuxt/src/runtime/utils/database-span-data.ts b/packages/nuxt/src/runtime/utils/database-span-data.ts
index d69368c92e1e..ed3a4804a656 100644
--- a/packages/nuxt/src/runtime/utils/database-span-data.ts
+++ b/packages/nuxt/src/runtime/utils/database-span-data.ts
@@ -1,3 +1,4 @@
+import { DB_NAMESPACE, SERVER_ADDRESS, SERVER_PORT } from '@sentry/conventions/attributes';
import type { ConnectorName } from 'db0';
export interface DatabaseSpanData {
@@ -27,27 +28,27 @@ export function getDatabaseSpanData(config?: DatabaseConnectionConfig): Partial<
if (!config?.connector) {
// Default to SQLite if no connector is configured
return {
- 'db.namespace': 'db.sqlite',
+ [DB_NAMESPACE]: 'db.sqlite',
};
}
if (config.connector === 'postgresql' || config.connector === 'mysql2') {
return {
- 'server.address': config.options?.host,
- 'server.port': config.options?.port,
+ [SERVER_ADDRESS]: config.options?.host,
+ [SERVER_PORT]: config.options?.port,
};
}
if (config.connector === 'pglite') {
return {
- 'db.namespace': config.options?.dataDir,
+ [DB_NAMESPACE]: config.options?.dataDir,
};
}
if ((['better-sqlite3', 'bun', 'sqlite', 'sqlite3'] as ConnectorName[]).includes(config.connector)) {
return {
// DB is the default file name in nitro for sqlite-like connectors
- 'db.namespace': `${config.options?.name ?? 'db'}.sqlite`,
+ [DB_NAMESPACE]: `${config.options?.name ?? 'db'}.sqlite`,
};
}
diff --git a/packages/nuxt/src/runtime/utils/instrumentDatabase.ts b/packages/nuxt/src/runtime/utils/instrumentDatabase.ts
index 71149f91ff19..6dc9a4d84818 100644
--- a/packages/nuxt/src/runtime/utils/instrumentDatabase.ts
+++ b/packages/nuxt/src/runtime/utils/instrumentDatabase.ts
@@ -5,18 +5,17 @@ import {
captureException,
DB_SPAN_NAME_FALLBACK,
debug,
- getClient,
- hasSpanStreamingEnabled,
type Span,
+ type SpanAttributes,
SPAN_STATUS_ERROR,
startSpan,
type StartSpanOptions,
} from '@sentry/core';
import { flushIfServerless } from '@sentry/core/server';
-import { sanitizeSqlQuery, sanitizeSqlQueryWithSummary, type SqlDialect } from '@sentry/server-utils';
+import { getSqlQueryAttributes, getSqlSpanName, sanitizeSqlQuery, type SqlDialect } from '@sentry/server-utils';
import type { Database, PreparedStatement } from 'db0';
import { type DatabaseConnectionConfig, type DatabaseSpanData, getDatabaseSpanData } from './database-span-data';
-import { DB_NAMESPACE, DB_QUERY_SUMMARY, DB_QUERY_TEXT, DB_SYSTEM_NAME } from '@sentry/conventions/attributes';
+import { DB_QUERY_TEXT, DB_SYSTEM_NAME } from '@sentry/conventions/attributes';
type MaybeInstrumentedDatabase = Database & {
__sentry_instrumented__?: boolean;
@@ -248,7 +247,7 @@ function createBreadcrumb(query: string, dialect: SqlDialect | undefined): void
category: 'query',
message: queryText,
data: {
- 'db.query.text': queryText,
+ [DB_QUERY_TEXT]: queryText,
},
});
}
@@ -261,22 +260,12 @@ function createStartSpanOptions(
data: DatabaseSpanData,
dialect: SqlDialect | undefined,
): StartSpanOptions {
- const { queryText, querySummary } = sanitizeSqlQueryWithSummary(query, dialect);
-
- const client = getClient();
- const name =
- client && hasSpanStreamingEnabled(client)
- ? querySummary || (data[DB_NAMESPACE] as string | undefined) || DB_SPAN_NAME_FALLBACK
- : (queryText ?? DB_SPAN_NAME_FALLBACK);
-
- return {
- name,
- attributes: {
- [DB_QUERY_TEXT]: queryText,
- [DB_QUERY_SUMMARY]: querySummary,
- [SENTRY_ORIGIN]: NUXT_DATABASE_ORIGIN,
- [SENTRY_OP]: DB_QUERY,
- ...data,
- },
+ const attributes: SpanAttributes = {
+ ...getSqlQueryAttributes(query, dialect),
+ [SENTRY_ORIGIN]: NUXT_DATABASE_ORIGIN,
+ [SENTRY_OP]: DB_QUERY,
+ ...data,
};
+
+ return { name: getSqlSpanName(attributes, DB_SPAN_NAME_FALLBACK), attributes };
}
diff --git a/packages/nuxt/src/runtime/utils/instrumentStorage.ts b/packages/nuxt/src/runtime/utils/instrumentStorage.ts
index 1480c1b7b32d..1055b4ca5876 100644
--- a/packages/nuxt/src/runtime/utils/instrumentStorage.ts
+++ b/packages/nuxt/src/runtime/utils/instrumentStorage.ts
@@ -1,4 +1,13 @@
-import { CACHE_HIT, CACHE_KEY, CACHE_OPERATION, SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes';
+import {
+ CACHE_HIT,
+ CACHE_KEY,
+ CACHE_OPERATION,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
+ DB_COLLECTION_NAME,
+ DB_OPERATION_NAME,
+ DB_SYSTEM_NAME,
+} from '@sentry/conventions/attributes';
import { CACHE_GET, CACHE_PUT, CACHE_REMOVE } from '@sentry/conventions/op';
import {
CACHE_OPERATION_NAMES,
@@ -80,7 +89,7 @@ const METHOD_SPAN_OPS = {
* The `useStorage` parameter is typed as `() => unknown` because nitropack (Nitro v2) and nitro (Nitro v3+) define `StorageValue` differently.
* Cast to `NitroStorage` is safe since all Nitro versions expose `getMounts()` at runtime.
*/
-export async function createStoragePlugin(useStorage: () => unknown, userStorageMounts: string[]): Promise {
+export function createStoragePlugin(useStorage: () => unknown, userStorageMounts: string[]): void {
// This runs at runtime when the Nitro server starts
const storage = useStorage() as NitroStorage;
// Mounts are suffixed with a colon, so we need to add it to the set items
@@ -240,9 +249,9 @@ function createSpanStartOptions(
[CACHE_OPERATION]: cacheOperationName,
[SENTRY_ORIGIN]: 'auto.cache.nuxt',
[CACHE_KEY]: keys.length > 1 ? keys : keys[0],
- 'db.operation.name': methodName,
- 'db.collection.name': mountBase.replace(/:$/, ''),
- 'db.system.name': driver.name ?? 'unknown',
+ [DB_OPERATION_NAME]: methodName,
+ [DB_COLLECTION_NAME]: mountBase.replace(/:$/, ''),
+ [DB_SYSTEM_NAME]: driver.name ?? 'unknown',
};
const client = getClient();
diff --git a/packages/nuxt/test/runtime/plugins/client.test.ts b/packages/nuxt/test/runtime/plugins/client.test.ts
index 0363ea9c1b94..16890e7be620 100644
--- a/packages/nuxt/test/runtime/plugins/client.test.ts
+++ b/packages/nuxt/test/runtime/plugins/client.test.ts
@@ -1,58 +1,283 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import plugin from '../../../src/runtime/plugins/sentry.client';
-import { reportNuxtError } from '../../../src/runtime/utils';
+import type { Client } from '@sentry/core';
+import type { isNuxtError as nuxtIsNuxtError, NuxtApp } from 'nuxt/app';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import clientPlugin from '../../../src/runtime/plugins/sentry.client';
+import * as utils from '../../../src/runtime/utils';
-vi.mock('nuxt/app', () => ({
- defineNuxtPlugin: (plugin: unknown) => plugin,
- isNuxtError: (error: unknown) => !!error && typeof error === 'object' && '__nuxt_error' in error,
+const getClient = vi.hoisted(() => vi.fn<() => Client | undefined>());
+const browserTracingIntegration = vi.hoisted(() => vi.fn());
+const vueIntegration = vi.hoisted(() => vi.fn());
+const createVueRouteProvider = vi.hoisted(() => vi.fn());
+const getRouteProvider = vi.hoisted(() => vi.fn());
+const setRouteProvider = vi.hoisted(() => vi.fn());
+
+const nuxtVueOptions = vi.hoisted(() => ({
+ trackComponents: ['NuxtApp'],
+ attachProps: false,
+}));
+const globalObj = vi.hoisted(() => ({
+ _sentryNuxtVueIntegrationOptions: nuxtVueOptions,
}));
-vi.mock('../../../src/runtime/utils', () => ({
- reportNuxtError: vi.fn(),
+vi.mock(import('@sentry/core'), async importOriginal => {
+ const mod = await importOriginal();
+ return {
+ ...mod,
+ getClient: getClient as typeof mod.getClient,
+ GLOBAL_OBJ: globalObj as never,
+ };
+});
+
+vi.mock(import('@sentry/vue'), async importOriginal => {
+ const mod = await importOriginal();
+ return {
+ ...mod,
+ browserTracingIntegration,
+ vueIntegration,
+ _INTERNAL_createVueRouteProvider: createVueRouteProvider,
+ getRouteProvider,
+ setRouteProvider,
+ };
+});
+
+vi.mock(import('nuxt/app'), () => ({
+ defineNuxtPlugin: vi.fn(plugin => plugin),
+ isNuxtError: vi.fn(() => false) as unknown as typeof nuxtIsNuxtError,
}));
-type Hook = (...args: unknown[]) => void;
+type PluginSetup = NonNullable;
-async function setupHook(hookName: string): Promise {
- const hooks: Record = {};
- const nuxtApp = { hook: (name: string, hook: Hook) => (hooks[name] = hook) };
- await (plugin as unknown as { setup: (app: unknown) => Promise }).setup(nuxtApp);
- expect(hooks).toHaveProperty(hookName);
- return hooks[hookName]!;
+type NuxtPluginShape = { setup: PluginSetup };
+
+type MockNuxtApp = NuxtApp & { hook: ReturnType };
+
+function makeClient(overrides: Partial = {}): Client {
+ return {
+ addIntegration: vi.fn(),
+ getOptions: vi.fn(() => ({ attachProps: true })),
+ ...overrides,
+ } as unknown as Client;
}
-function nuxtError(statusCode: number): Error {
- return Object.assign(new Error(`status ${statusCode}`), { __nuxt_error: true, statusCode });
+function makeNuxtApp(router?: unknown): MockNuxtApp {
+ return (router === undefined ? { hook: vi.fn() } : { $router: router, hook: vi.fn() }) as unknown as MockNuxtApp;
}
-describe('sentry client plugin error hooks', () => {
- beforeEach(() => {
- vi.clearAllMocks();
+describe('Sentry Nuxt client plugin', () => {
+ afterEach(() => {
+ vi.resetAllMocks();
+ });
+
+ describe('route provider setup', () => {
+ it('installs a Vue route provider when a Nuxt router is present and none is set', async () => {
+ const router = { beforeEach: vi.fn(), onError: vi.fn() };
+ const client = makeClient();
+ const provider = { resolveRoute: vi.fn(), resolveCurrentRoute: vi.fn() };
+ getClient.mockReturnValue(client);
+ getRouteProvider.mockReturnValue(undefined);
+ createVueRouteProvider.mockReturnValue(provider);
+
+ await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp(router));
+
+ expect(createVueRouteProvider).toHaveBeenCalledWith(expect.any(Function));
+ expect(setRouteProvider).toHaveBeenCalledWith(provider, client);
+ });
+
+ it('does not override a user-supplied route provider', async () => {
+ const client = makeClient();
+ getClient.mockReturnValue(client);
+ getRouteProvider.mockReturnValue({ resolveRoute: () => '/u', resolveCurrentRoute: () => '/u' });
+
+ await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp({}));
+
+ expect(createVueRouteProvider).not.toHaveBeenCalled();
+ expect(setRouteProvider).not.toHaveBeenCalled();
+ });
+
+ it('skips route provider setup without an initialized client', async () => {
+ getClient.mockReturnValue(undefined);
+
+ await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp({}));
+
+ expect(createVueRouteProvider).not.toHaveBeenCalled();
+ expect(setRouteProvider).not.toHaveBeenCalled();
+ });
+
+ it('skips route provider setup without a $router on the Nuxt app', async () => {
+ const client = makeClient();
+ getClient.mockReturnValue(client);
+ getRouteProvider.mockReturnValue(undefined);
+
+ await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp());
+
+ expect(createVueRouteProvider).not.toHaveBeenCalled();
+ expect(setRouteProvider).not.toHaveBeenCalled();
+ });
});
- it.each(['app:error', 'vue:error'])('does not report 3xx/4xx Nuxt errors from %s', async hookName => {
- const hook = await setupHook(hookName);
+ describe('browser tracing integration', () => {
+ it('adds browserTracingIntegration with the Nuxt router and path-based labels', async () => {
+ const router = { beforeEach: vi.fn(), onError: vi.fn() };
+ const integration = { name: 'BrowserTracing' };
+ const client = makeClient();
+ getClient.mockReturnValue(client);
+ getRouteProvider.mockReturnValue(undefined);
+ browserTracingIntegration.mockReturnValue(integration);
- hook(nuxtError(404));
- hook(nuxtError(301));
+ await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp(router));
- expect(reportNuxtError).not.toHaveBeenCalled();
+ expect(browserTracingIntegration).toHaveBeenCalledWith({ router, routeLabel: 'path' });
+ expect(client.addIntegration).toHaveBeenCalledWith(integration);
+ });
+
+ it('does not add browserTracingIntegration without an initialized client', async () => {
+ getClient.mockReturnValue(undefined);
+
+ await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp({ beforeEach: vi.fn() }));
+
+ expect(browserTracingIntegration).not.toHaveBeenCalled();
+ });
+
+ it('does not add browserTracingIntegration when the Nuxt app has no router', async () => {
+ const client = makeClient();
+ getClient.mockReturnValue(client);
+ getRouteProvider.mockReturnValue(undefined);
+
+ await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp());
+
+ expect(browserTracingIntegration).not.toHaveBeenCalled();
+ });
});
- it.each(['app:error', 'vue:error'])('reports 5xx Nuxt errors from %s', async hookName => {
- const hook = await setupHook(hookName);
+ describe('app:created hook — Vue integration', () => {
+ it('adds the Nuxt-specific Vue integration with attachErrorHandler disabled and merges GLOBAL_OBJ-provided options', async () => {
+ const client = makeClient();
+ const integration = { name: 'NuxtVueIntegration' };
+ const vueApp = { Symbol: 'app' };
+ getClient.mockReturnValue(client);
+ getRouteProvider.mockReturnValue(undefined);
+ vueIntegration.mockReturnValue(integration);
+
+ const nuxtApp = makeNuxtApp();
+ await (clientPlugin as NuxtPluginShape).setup(nuxtApp);
+ const appCreatedCall = nuxtApp.hook.mock.calls.find(c => c[0] === 'app:created');
+ expect(appCreatedCall).toBeTruthy();
+ const appCreatedCb = appCreatedCall![1] as (app: unknown) => void;
+ appCreatedCb(vueApp);
+
+ // Nuxt-specific: _sentryNuxtVueIntegrationOptions from GLOBAL_OBJ must
+ // flow into vueIntegration alongside the fixed Nuxt options.
+ expect(vueIntegration).toHaveBeenCalledWith(
+ expect.objectContaining({
+ app: vueApp,
+ attachErrorHandler: false,
+ trackComponents: nuxtVueOptions.trackComponents,
+ attachProps: nuxtVueOptions.attachProps,
+ }),
+ );
+ expect(client.addIntegration).toHaveBeenCalledWith(integration);
+ });
+
+ it('does not add the Vue integration when the client is gone by the time the hook fires', async () => {
+ const client = makeClient();
+ getClient.mockReturnValueOnce(client);
+ getRouteProvider.mockReturnValue(undefined);
+
+ const nuxtApp = makeNuxtApp();
+ await (clientPlugin as NuxtPluginShape).setup(nuxtApp);
+
+ // Second getClient call (inside the hook) returns undefined — simulates teardown.
+ getClient.mockReturnValueOnce(undefined);
+ const appCreatedCb = nuxtApp.hook.mock.calls.find(c => c[0] === 'app:created')?.[1] as (app: unknown) => void;
+ appCreatedCb({});
+
+ expect(vueIntegration).not.toHaveBeenCalled();
+ });
+ });
+
+ describe.each(['app:error', 'vue:error'] as const)('%s hook — status-code filtering', hookName => {
+ const fireHook = (nuxtApp: { hook: ReturnType }, error: unknown) => {
+ const cb = nuxtApp.hook.mock.calls.find(c => c[0] === hookName)?.[1] as (
+ e: unknown,
+ instance?: unknown,
+ info?: string,
+ ) => void;
+ // vue:error carries (error, instance, info); app:error only (error). The
+ // plugin passes the extra args through for vue:error, so include them
+ // uniformly here and rely on expect.objectContaining({ error }) below.
+ cb(error, undefined, 'setup');
+ };
+
+ // The plugin's skip guard reads `error.status || error.statusCode`, so both
+ // field names in the 3xx/4xx range must be covered.
+ it.each([{ status: 404 }, { status: 301 }, { statusCode: 404 }, { statusCode: 301 }])(
+ 'skips reporting when a Nuxt error carries %o',
+ async errorShape => {
+ const { isNuxtError } = await import('nuxt/app');
+ const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {});
+ vi.mocked(isNuxtError).mockReturnValue(true);
+ getClient.mockReturnValue(makeClient());
+ getRouteProvider.mockReturnValue(undefined);
+
+ const nuxtApp = makeNuxtApp();
+ await (clientPlugin as NuxtPluginShape).setup(nuxtApp);
+ fireHook(nuxtApp, errorShape);
+
+ expect(reportSpy).not.toHaveBeenCalled();
+ },
+ );
+
+ it('reports a Nuxt error when the status code is 5xx', async () => {
+ const { isNuxtError } = await import('nuxt/app');
+ const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {});
+ vi.mocked(isNuxtError).mockReturnValue(true);
+ getClient.mockReturnValue(makeClient());
+ getRouteProvider.mockReturnValue(undefined);
+
+ const nuxtApp = makeNuxtApp();
+ await (clientPlugin as NuxtPluginShape).setup(nuxtApp);
+ const err = { statusCode: 503, message: 'bad gateway' };
+ fireHook(nuxtApp, err);
+
+ expect(reportSpy).toHaveBeenCalledWith(expect.objectContaining({ error: err }));
+ });
+
+ // Non-Nuxt error carrying a 4xx statusCode exercises the `isNuxtError`
+ // guard directly — without the guard this would be skipped as 4xx.
+ it('reports a non-Nuxt error even when it carries a 4xx statusCode', async () => {
+ const { isNuxtError } = await import('nuxt/app');
+ const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {});
+ vi.mocked(isNuxtError).mockReturnValue(false);
+ getClient.mockReturnValue(makeClient());
+ getRouteProvider.mockReturnValue(undefined);
- hook(nuxtError(503));
+ const nuxtApp = makeNuxtApp();
+ await (clientPlugin as NuxtPluginShape).setup(nuxtApp);
+ const err = Object.assign(new Error('boom'), { statusCode: 404 });
+ fireHook(nuxtApp, err);
- expect(reportNuxtError).toHaveBeenCalledTimes(1);
+ expect(reportSpy).toHaveBeenCalledWith(expect.objectContaining({ error: err }));
+ });
});
- it('reports errors that are not Nuxt errors from vue:error', async () => {
- const hook = await setupHook('vue:error');
- const error = Object.assign(new Error('boom'), { statusCode: 404 });
+ describe('vue:error hook — component instance passthrough', () => {
+ it('forwards the error, component instance and info to reportNuxtError', async () => {
+ const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {});
+ getClient.mockReturnValue(makeClient());
+ getRouteProvider.mockReturnValue(undefined);
- hook(error, undefined, 'setup function');
+ const nuxtApp = makeNuxtApp();
+ await (clientPlugin as NuxtPluginShape).setup(nuxtApp);
+ const vueErrorCb = nuxtApp.hook.mock.calls.find(c => c[0] === 'vue:error')?.[1] as (
+ e: unknown,
+ instance: unknown,
+ info: string,
+ ) => void;
+ const err = new Error('vue boom');
+ const instance = { $props: { foo: 1 } };
+ vueErrorCb(err, instance, 'render');
- expect(reportNuxtError).toHaveBeenCalledWith({ error, instance: undefined, info: 'setup function' });
+ expect(reportSpy).toHaveBeenCalledWith({ error: err, instance, info: 'render' });
+ });
});
});
diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json
index 13abf6404179..84d66f62fcac 100644
--- a/packages/opentelemetry/package.json
+++ b/packages/opentelemetry/package.json
@@ -27,7 +27,7 @@
"access": "public"
},
"dependencies": {
- "@sentry/conventions": "^0.29.0",
+ "@sentry/conventions": "^0.30.0",
"@sentry/core": "11.10.0"
},
"peerDependencies": {
diff --git a/packages/profiling-node/package.json b/packages/profiling-node/package.json
index eb480d1c7dc0..467b409192eb 100644
--- a/packages/profiling-node/package.json
+++ b/packages/profiling-node/package.json
@@ -50,6 +50,7 @@
"test:watch": "vitest --watch"
},
"dependencies": {
+ "@sentry/conventions": "^0.30.0",
"@sentry/node-cpu-profiler": "^2.4.4",
"@sentry/core": "11.10.0",
"@sentry/node": "11.10.0"
diff --git a/packages/profiling-node/src/integration.ts b/packages/profiling-node/src/integration.ts
index 0832bd379c11..21f5e6afa900 100644
--- a/packages/profiling-node/src/integration.ts
+++ b/packages/profiling-node/src/integration.ts
@@ -1,4 +1,5 @@
/* eslint-disable max-lines */
+import { THREAD_ID, THREAD_NAME } from '@sentry/conventions/attributes';
import type { Event, IntegrationFn, ProfileChunk, ProfilingIntegration } from '@sentry/core';
import {
consoleSandbox,
@@ -424,8 +425,8 @@ class ContinuousProfiler {
...(event.contexts?.trace ?? {}),
data: {
...(event.contexts?.trace?.data ?? {}),
- ['thread.id']: PROFILER_THREAD_ID_STRING,
- ['thread.name']: PROFILER_THREAD_NAME,
+ [THREAD_ID]: PROFILER_THREAD_ID_STRING,
+ [THREAD_NAME]: PROFILER_THREAD_NAME,
},
};
}
diff --git a/packages/react-router/package.json b/packages/react-router/package.json
index b0873a87fdcc..01b041108f5f 100644
--- a/packages/react-router/package.json
+++ b/packages/react-router/package.json
@@ -57,26 +57,25 @@
"dependencies": {
"@opentelemetry/api": "^1.9.1",
"@sentry/browser": "11.10.0",
- "@sentry/conventions": "^0.29.0",
+ "@sentry/conventions": "^0.30.0",
"@sentry/core": "11.10.0",
"@sentry/node": "11.10.0",
"@sentry/react": "11.10.0",
"@sentry/server-runtime-injection": "11.10.0",
"@sentry/server-utils": "11.10.0",
"@sentry/bundler-plugins": "11.10.0",
- "glob": "^13.0.6",
"sentry": "^0.44.0"
},
"devDependencies": {
- "@react-router/dev": "^7.17.0",
+ "@react-router/dev": "^7.18.4",
"@react-router/node": "^7.15.0",
"@sentry-internal/rollup-utils": "11.10.0",
"@testing-library/react": "^15.0.5",
"@types/node": "^20.19.0",
"@types/react": "17.0.3",
"react": "^18.3.1",
- "react-router": "^7.18.0",
- "vite": "^8.3.1"
+ "react-router": "^7.18.4",
+ "vite": "^8.3.5"
},
"peerDependencies": {
"@react-router/node": ">=7.15.0 || ^8.x",
diff --git a/packages/react-router/src/client/createClientInstrumentation.ts b/packages/react-router/src/client/createClientInstrumentation.ts
index fb77746120fc..cacbc2b8efeb 100644
--- a/packages/react-router/src/client/createClientInstrumentation.ts
+++ b/packages/react-router/src/client/createClientInstrumentation.ts
@@ -32,6 +32,8 @@ import {
URL_FULL,
URL_TEMPLATE,
SENTRY_ORIGIN,
+ HTTP_ROUTE,
+ NAVIGATION_TYPE,
} from '@sentry/conventions/attributes';
import { FUNCTION, MIDDLEWARE, NAVIGATION } from '@sentry/conventions/op';
@@ -120,7 +122,8 @@ export function createSentryClientInstrumentation(
[SENTRY_SEGMENT_NAME_SOURCE]: 'url',
[SENTRY_OP]: NAVIGATION,
[SENTRY_ORIGIN]: 'auto.navigation.react_router.instrumentation_api',
- 'navigation.type': 'browser.popstate',
+ // oxlint-disable-next-line typescript/no-deprecated -- Preserve the existing attribute name.
+ [NAVIGATION_TYPE]: 'browser.popstate',
},
},
{ url: getAbsoluteUrl(pathname) },
@@ -165,7 +168,8 @@ export function createSentryClientInstrumentation(
[SENTRY_SEGMENT_NAME_SOURCE]: 'url',
[SENTRY_OP]: NAVIGATION,
[SENTRY_ORIGIN]: 'auto.navigation.react_router.instrumentation_api',
- 'navigation.type': navigationType,
+ // oxlint-disable-next-line typescript/no-deprecated -- Preserve the existing attribute name.
+ [NAVIGATION_TYPE]: navigationType,
},
},
{ url: getAbsoluteUrl(currentPathname) },
@@ -212,7 +216,8 @@ export function createSentryClientInstrumentation(
[SENTRY_SEGMENT_NAME_SOURCE]: 'url',
[SENTRY_OP]: NAVIGATION,
[SENTRY_ORIGIN]: 'auto.navigation.react_router.instrumentation_api',
- 'navigation.type': 'router.navigate',
+ // oxlint-disable-next-line typescript/no-deprecated -- Preserve the existing attribute name.
+ [NAVIGATION_TYPE]: 'router.navigate',
},
},
{ url: resolveNavigateAbsoluteUrl(info.to, info.currentUrl) },
@@ -358,7 +363,7 @@ export function createSentryClientInstrumentation(
[CODE_FUNCTION_NAME]: 'clientMiddleware',
[SENTRY_ORIGIN]: 'auto.function.react_router.instrumentation_api',
'react_router.route.id': routeId,
- 'http.route': routePattern,
+ [HTTP_ROUTE]: routePattern,
'react_router.middleware.index': middlewareIndex,
},
},
diff --git a/packages/react-router/src/server/createServerInstrumentation.ts b/packages/react-router/src/server/createServerInstrumentation.ts
index ebf16d85c87d..892663c37a46 100644
--- a/packages/react-router/src/server/createServerInstrumentation.ts
+++ b/packages/react-router/src/server/createServerInstrumentation.ts
@@ -240,7 +240,7 @@ export function createSentryServerInstrumentation(
[SENTRY_ORIGIN]: 'auto.function.react_router.instrumentation_api',
'react_router.route.id': routeId,
[HTTP_ROUTE]: routePattern,
- ...(middlewareName && { 'react_router.middleware.name': middlewareName }),
+ 'react_router.middleware.name': middlewareName,
'react_router.middleware.index': middlewareIndex,
},
},
diff --git a/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts b/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts
index a4cf0403e248..0f83b4553ecb 100644
--- a/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts
+++ b/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts
@@ -1,8 +1,9 @@
import { rm } from 'node:fs/promises';
+import * as path from 'node:path';
import type { Config } from '@react-router/dev/config';
import { createSentrySDK } from 'sentry';
+import { globFiles } from '@sentry/bundler-plugins/core';
import type { SentryVitePluginOptions } from '@sentry/bundler-plugins/vite';
-import { glob } from 'glob';
import type { SentryReactRouterBuildOptions } from '../types';
type BuildEndHook = NonNullable;
@@ -24,6 +25,11 @@ function getSentryConfig(viteConfig: unknown): SentryReactRouterBuildOptions {
return (viteConfig as { sentryConfig: SentryReactRouterBuildOptions }).sentryConfig;
}
+/** Glob patterns need forward slashes, but React Router resolves `buildDirectory` with the platform separator. */
+function toGlobPath(directory: string): string {
+ return directory.split(path.sep).join('/');
+}
+
/**
* A build end hook that handles Sentry release creation and source map uploads.
* It creates a new Sentry release if configured, uploads source maps to Sentry,
@@ -123,7 +129,7 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo
// set a default value no option was set
if (typeof updatedFilesToDeleteAfterUpload === 'undefined') {
- updatedFilesToDeleteAfterUpload = [`${reactRouterConfig.buildDirectory}/**/*.map`];
+ updatedFilesToDeleteAfterUpload = [`${toGlobPath(reactRouterConfig.buildDirectory)}/**/*.map`];
debug &&
// eslint-disable-next-line no-console
console.info(
@@ -134,10 +140,7 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo
}
if (updatedFilesToDeleteAfterUpload) {
try {
- const filePathsToDelete = await glob(updatedFilesToDeleteAfterUpload, {
- absolute: true,
- nodir: true,
- });
+ const filePathsToDelete = await globFiles(updatedFilesToDeleteAfterUpload);
if (debug) {
filePathsToDelete.forEach(filePathToDelete => {
// eslint-disable-next-line no-console
diff --git a/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts b/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts
index 560552ed278d..548fea3b7145 100644
--- a/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts
+++ b/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts
@@ -1,6 +1,7 @@
import { createSentrySDK } from 'sentry';
import { rm } from 'node:fs/promises';
-import { glob } from 'glob';
+import type * as path from 'node:path';
+import { globFiles } from '@sentry/bundler-plugins/core';
import type { ResolvedConfig } from 'vite';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { sentryOnBuildEnd } from '../../../src/vite/buildEnd/handleOnBuildEnd';
@@ -11,7 +12,7 @@ vi.mock('node:fs/promises', () => {
const rm = vi.fn().mockResolvedValue(undefined);
return { rm, default: { rm } };
});
-vi.mock('glob');
+vi.mock('@sentry/bundler-plugins/core', () => ({ globFiles: vi.fn() }));
type TestConfig = ResolvedConfig & {
sentryConfig: SentryReactRouterBuildOptions;
@@ -62,7 +63,7 @@ describe('sentryOnBuildEnd', () => {
vi.mocked(createSentrySDK)
.mockReset()
.mockReturnValue(mockSentrySdkInstance as unknown as ReturnType);
- vi.mocked(glob).mockResolvedValue(['/build/file1.map', '/build/file2.map']);
+ vi.mocked(globFiles).mockResolvedValue(['/build/file1.map', '/build/file2.map']);
vi.mocked(rm).mockResolvedValue(undefined);
});
@@ -126,10 +127,7 @@ describe('sentryOnBuildEnd', () => {
directory: '/build',
release: '1.2.3',
});
- expect(glob).toHaveBeenCalledWith(['./build/custom/**/*.map'], {
- absolute: true,
- nodir: true,
- });
+ expect(globFiles).toHaveBeenCalledWith(['./build/custom/**/*.map']);
});
it('should upload source maps when enabled', async () => {
@@ -196,7 +194,7 @@ describe('sentryOnBuildEnd', () => {
expect(mockSentrySdkInstance.sourcemap.inject).not.toHaveBeenCalled();
expect(mockSentrySdkInstance.sourcemap.upload).not.toHaveBeenCalled();
- expect(glob).not.toHaveBeenCalled();
+ expect(globFiles).not.toHaveBeenCalled();
expect(rm).not.toHaveBeenCalled();
});
@@ -219,7 +217,37 @@ describe('sentryOnBuildEnd', () => {
expect(mockSentrySdkInstance.sourcemap.inject).toHaveBeenCalledWith({ directory: '/build' });
expect(mockSentrySdkInstance.sourcemap.upload).not.toHaveBeenCalled();
- expect(glob).not.toHaveBeenCalled();
+ expect(globFiles).not.toHaveBeenCalled();
+ });
+
+ it('deletes the source maps in the build directory by default', async () => {
+ // @ts-expect-error - mocking the React config
+ await sentryOnBuildEnd(defaultConfig);
+
+ expect(globFiles).toHaveBeenCalledWith(['/build/**/*.map']);
+ expect(rm).toHaveBeenCalledWith('/build/file1.map', { force: true });
+ expect(rm).toHaveBeenCalledWith('/build/file2.map', { force: true });
+ });
+
+ it('builds the default deletion pattern with forward slashes on Windows', async () => {
+ vi.doMock('node:path', async importOriginal => ({
+ ...(await importOriginal()),
+ sep: '\\',
+ }));
+ const { sentryOnBuildEnd: sentryOnBuildEndWin32 } = await import('../../../src/vite/buildEnd/handleOnBuildEnd');
+ const { globFiles: globFilesWin32 } = await import('@sentry/bundler-plugins/core');
+ vi.mocked(globFilesWin32).mockResolvedValue([]);
+
+ const config = {
+ ...defaultConfig,
+ reactRouterConfig: { ...defaultConfig.reactRouterConfig, buildDirectory: 'C:\\app\\build' },
+ };
+
+ // @ts-expect-error - mocking the React config
+ await sentryOnBuildEndWin32(config);
+
+ expect(globFilesWin32).toHaveBeenCalledWith(['C:/app/build/**/*.map']);
+ vi.doUnmock('node:path');
});
it('should delete custom files after upload when specified', async () => {
@@ -239,10 +267,7 @@ describe('sentryOnBuildEnd', () => {
// @ts-expect-error - mocking the React config
await sentryOnBuildEnd(config);
- expect(glob).toHaveBeenCalledWith('/custom/**/*.map', {
- absolute: true,
- nodir: true,
- });
+ expect(globFiles).toHaveBeenCalledWith('/custom/**/*.map');
});
it('should handle errors during release creation gracefully', async () => {
diff --git a/packages/react/package.json b/packages/react/package.json
index f5e67bc142be..ffb392889d3b 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -59,7 +59,7 @@
"dependencies": {
"@sentry/browser": "11.10.0",
"@sentry/core": "11.10.0",
- "@sentry/conventions": "^0.29.0"
+ "@sentry/conventions": "^0.30.0"
},
"peerDependencies": {
"react": "17.x || 18.x || 19.x",
@@ -84,12 +84,12 @@
"history-5": "npm:history@4.9.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "react-router": "^7.18.3",
+ "react-router": "^7.18.4",
"react-router-3": "npm:react-router@3.2.0",
"react-router-4": "npm:react-router@4.1.0",
"react-router-5": "npm:react-router@5.3.4",
"react-router-6": "npm:react-router@6.30.4",
- "redux": "^4.0.5"
+ "redux": "^5.0.1"
},
"scripts": {
"build": "run-p build:transpile build:types",
diff --git a/packages/react/src/profiler.tsx b/packages/react/src/profiler.tsx
index 508d9e508bdf..a81a50199c7a 100644
--- a/packages/react/src/profiler.tsx
+++ b/packages/react/src/profiler.tsx
@@ -66,7 +66,7 @@ class Profiler extends React.Component {
attributes: {
[SENTRY_OP]: UI_MOUNT,
[SENTRY_ORIGIN]: 'auto.ui.react.profiler',
- ...(componentName && { [UI_COMPONENT_NAME]: componentName }),
+ [UI_COMPONENT_NAME]: componentName || undefined,
...(hasSpanStreaming && { [SENTRY_DESCRIPTION]: description }),
},
});
@@ -102,7 +102,7 @@ class Profiler extends React.Component {
attributes: {
[SENTRY_OP]: UI_UPDATE,
[SENTRY_ORIGIN]: 'auto.ui.react.profiler',
- ...(componentName && { [UI_COMPONENT_NAME]: componentName }),
+ [UI_COMPONENT_NAME]: componentName || undefined,
'ui.react.changed_props': changedProps,
...(hasSpanStreaming && { [SENTRY_DESCRIPTION]: description }),
},
@@ -142,7 +142,7 @@ class Profiler extends React.Component {
attributes: {
[SENTRY_OP]: UI_RENDER,
[SENTRY_ORIGIN]: 'auto.ui.react.profiler',
- ...(componentName && { [UI_COMPONENT_NAME]: componentName }),
+ [UI_COMPONENT_NAME]: componentName || undefined,
...(hasSpanStreaming && { [SENTRY_DESCRIPTION]: description }),
},
});
@@ -229,7 +229,7 @@ function useProfiler(
attributes: {
[SENTRY_OP]: UI_MOUNT,
[SENTRY_ORIGIN]: 'auto.ui.react.profiler',
- ...(componentName && { [UI_COMPONENT_NAME]: componentName }),
+ [UI_COMPONENT_NAME]: componentName || undefined,
...(hasSpanStreaming && { [SENTRY_DESCRIPTION]: description }),
},
});
@@ -257,7 +257,7 @@ function useProfiler(
attributes: {
[SENTRY_OP]: UI_RENDER,
[SENTRY_ORIGIN]: 'auto.ui.react.profiler',
- ...(componentName && { [UI_COMPONENT_NAME]: componentName }),
+ [UI_COMPONENT_NAME]: componentName || undefined,
...(hasSpanStreaming && { [SENTRY_DESCRIPTION]: description }),
},
});
diff --git a/packages/react/src/tanstackrouter.ts b/packages/react/src/tanstackrouter.ts
index 41eec4694aae..e7f1c20f8e2e 100644
--- a/packages/react/src/tanstackrouter.ts
+++ b/packages/react/src/tanstackrouter.ts
@@ -106,7 +106,7 @@ export function tanstackRouterBrowserTracingIntegration(
[SENTRY_OP]: PAGELOAD,
[SENTRY_ORIGIN]: 'auto.pageload.react.tanstack_router',
[SENTRY_SEGMENT_NAME_SOURCE]: routeMatch ? 'route' : 'url',
- ...(routeMatch && { [URL_TEMPLATE]: routeMatch.routeId }),
+ [URL_TEMPLATE]: routeMatch?.routeId,
...routeMatchToParamSpanAttributes(routeMatch),
},
});
@@ -163,7 +163,7 @@ export function tanstackRouterBrowserTracingIntegration(
[SENTRY_OP]: NAVIGATION,
[SENTRY_ORIGIN]: 'auto.navigation.react.tanstack_router',
[SENTRY_SEGMENT_NAME_SOURCE]: routeMatch ? 'route' : 'url',
- ...(routeMatch && { [URL_TEMPLATE]: routeMatch.routeId }),
+ [URL_TEMPLATE]: routeMatch?.routeId,
...routeMatchToParamSpanAttributes(routeMatch),
},
},
diff --git a/packages/remix/package.json b/packages/remix/package.json
index 4ae981053393..12cc9c654b19 100644
--- a/packages/remix/package.json
+++ b/packages/remix/package.json
@@ -78,14 +78,14 @@
"@remix-run/router": "^1.23.4",
"@sentry/browser": "11.10.0",
"@sentry/bundler-plugins": "11.10.0",
- "@sentry/conventions": "^0.29.0",
+ "@sentry/conventions": "^0.30.0",
"@sentry/core": "11.10.0",
"@sentry/node": "11.10.0",
"@sentry/react": "11.10.0",
"@sentry/server-runtime-injection": "11.10.0",
"@sentry/server-utils": "11.10.0",
"sentry": "^0.44.0",
- "yargs": "^17.6.0"
+ "yargs": "^18.2.0"
},
"devDependencies": {
"@remix-run/node": "^2.17.5",
@@ -99,7 +99,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"semver": "^7.8.5",
- "vite": "^8.3.1"
+ "vite": "^8.3.5"
},
"peerDependencies": {
"@remix-run/node": "2.x",
diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts
index a1aaa76b57aa..e034d9404d05 100644
--- a/packages/remix/src/server/index.ts
+++ b/packages/remix/src/server/index.ts
@@ -37,6 +37,9 @@ export {
flush,
functionToStringIntegration,
genericPoolIntegration,
+ cronJobIntegration,
+ nodeCronIntegration,
+ nodeScheduleIntegration,
getActiveSpan,
getAutoPerformanceIntegrations,
getClient,
diff --git a/packages/remix/src/server/integrations/tracing-channel.ts b/packages/remix/src/server/integrations/tracing-channel.ts
index 8e93813a19dd..65e6bda2e463 100644
--- a/packages/remix/src/server/integrations/tracing-channel.ts
+++ b/packages/remix/src/server/integrations/tracing-channel.ts
@@ -192,6 +192,7 @@ function subscribeCallRouteLoader(): void {
// With span streaming, a `function` span is named after the function it wraps. The route id
// stays on `match.route.id`.
name: client && hasSpanStreamingEnabled(client) ? 'loader' : description,
+ onlyIfParent: true,
attributes: {
[SENTRY_ORIGIN]: ORIGIN,
[SENTRY_OP]: FUNCTION,
@@ -203,7 +204,6 @@ function subscribeCallRouteLoader(): void {
});
},
{
- requiresParentSpan: true,
beforeSpanEnd: (span, data) => setResponseStatus(span, data.result),
},
);
@@ -231,6 +231,7 @@ function subscribeCallRouteAction(formDataCapture: FormDataCapture | undefined):
// With span streaming, a `function` span is named after the function it wraps. The route id
// stays on `match.route.id`.
name: client && hasSpanStreamingEnabled(client) ? 'action' : description,
+ onlyIfParent: true,
attributes: {
[SENTRY_ORIGIN]: ORIGIN,
[SENTRY_OP]: FUNCTION,
@@ -242,7 +243,6 @@ function subscribeCallRouteAction(formDataCapture: FormDataCapture | undefined):
});
},
{
- requiresParentSpan: true,
beforeSpanEnd: (span, data) => setResponseStatus(span, data.result),
// Hold the span end until the (already in-flight) form-data read resolves, then apply the
// attributes and end (which sets the response status via `beforeSpanEnd`). On error, or when
diff --git a/packages/remix/src/v3/client/browserTracingIntegration.ts b/packages/remix/src/v3/client/browserTracingIntegration.ts
index 5e07ca200934..3142c834c966 100644
--- a/packages/remix/src/v3/client/browserTracingIntegration.ts
+++ b/packages/remix/src/v3/client/browserTracingIntegration.ts
@@ -107,7 +107,7 @@ function instrumentNavigationApi(client: Client): void {
name: route ?? (hasSpanStreamingEnabled(client) ? NAVIGATION_SPAN_NAME_FALLBACK : pathname),
attributes: {
[SENTRY_SEGMENT_NAME_SOURCE]: route ? 'route' : 'url',
- ...(route && { [URL_TEMPLATE]: route }),
+ [URL_TEMPLATE]: route,
[SENTRY_ORIGIN]: 'auto.navigation.remix_v3',
},
},
diff --git a/packages/remix/test/server/tracing-channel.test.ts b/packages/remix/test/server/tracing-channel.test.ts
index c11200f643b1..f31f9c693439 100644
--- a/packages/remix/test/server/tracing-channel.test.ts
+++ b/packages/remix/test/server/tracing-channel.test.ts
@@ -27,7 +27,6 @@ describe('remixIntegration (Orchestrion-based)', () => {
beforeEach(() => {
span = makeSpan();
startInactiveSpanSpy = vi.spyOn(SentryCore, 'startInactiveSpan').mockReturnValue(span);
- // A truthy active span by default, so the `requiresParentSpan` gate passes.
getActiveSpanSpy = vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue({} as Span);
});
@@ -129,13 +128,13 @@ describe('remixIntegration (Orchestrion-based)', () => {
expect(span.end).toHaveBeenCalledTimes(1);
});
- it('callRouteLoader: does not create a span without an active parent span', async () => {
+ it('callRouteLoader: does not create a recording span without an active parent span', async () => {
getActiveSpanSpy.mockReturnValue(undefined);
const ctx = { arguments: [{ routeId: 'x', request: makeRequest(), params: {} }] };
await tracingChannel(remixChannels.REMIX_CALL_ROUTE_LOADER).tracePromise(async () => ({ status: 200 }), ctx);
- expect(startInactiveSpanSpy).not.toHaveBeenCalled();
+ expect(startInactiveSpanSpy).toHaveBeenCalledWith(expect.objectContaining({ onlyIfParent: true }));
});
it('callRouteAction: builds an ACTION span and captures the configured form-data keys', async () => {
diff --git a/packages/replay-internal/package.json b/packages/replay-internal/package.json
index df27b282c52b..88cc236a18f2 100644
--- a/packages/replay-internal/package.json
+++ b/packages/replay-internal/package.json
@@ -74,11 +74,10 @@
"@babel/core": "^7.29.6",
"@sentry-internal/replay-worker": "11.10.0",
"@sentry-internal/rollup-utils": "11.10.0",
- "@sentry/conventions": "^0.29.0",
+ "@sentry/conventions": "^0.30.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"
},
"dependencies": {
diff --git a/packages/replay-internal/src/coreHandlers/util/shouldSampleForBufferEvent.ts b/packages/replay-internal/src/coreHandlers/util/shouldSampleForBufferEvent.ts
index 3a7a8f423dba..c73303f794c7 100644
--- a/packages/replay-internal/src/coreHandlers/util/shouldSampleForBufferEvent.ts
+++ b/packages/replay-internal/src/coreHandlers/util/shouldSampleForBufferEvent.ts
@@ -24,5 +24,9 @@ export function shouldSampleForBufferEvent(replay: ReplayContainer, event: Event
return false;
}
+ if (event.sdkProcessingMetadata?.hasDefaultSyntheticStacktrace) {
+ return false;
+ }
+
return isSampled(replay.getOptions().errorSampleRate);
}
diff --git a/packages/replay-internal/src/integration.ts b/packages/replay-internal/src/integration.ts
index 547b2417090e..0576e696383d 100644
--- a/packages/replay-internal/src/integration.ts
+++ b/packages/replay-internal/src/integration.ts
@@ -1,3 +1,4 @@
+import { SENTRY_REPLAY_ID } from '@sentry/conventions/attributes';
import type { Client, Integration, IntegrationFn, ReplayRecordingMode, StreamedSpanJSON } from '@sentry/core';
import type { BrowserClientReplayOptions } from '@sentry/core/browser';
import { consoleSandbox, GLOBAL_OBJ, isBrowser, parseSampleRate, safeSetSpanJSONAttributes } from '@sentry/core';
@@ -368,7 +369,7 @@ export class Replay implements Integration {
public processSpan(span: StreamedSpanJSON): void {
const replayId = this.getReplayId(true);
if (replayId) {
- safeSetSpanJSONAttributes(span, { 'sentry.replay_id': replayId });
+ safeSetSpanJSONAttributes(span, { [SENTRY_REPLAY_ID]: replayId });
if (this.getRecordingMode() === 'buffer') {
safeSetSpanJSONAttributes(span, { 'sentry._internal.replay_is_buffering': true });
}
diff --git a/packages/replay-internal/test/integration/coreHandlers/handleGlobalEvent.test.ts b/packages/replay-internal/test/integration/coreHandlers/handleGlobalEvent.test.ts
index 6c7230e2c680..95df766bb69f 100644
--- a/packages/replay-internal/test/integration/coreHandlers/handleGlobalEvent.test.ts
+++ b/packages/replay-internal/test/integration/coreHandlers/handleGlobalEvent.test.ts
@@ -209,6 +209,18 @@ describe('Integration | coreHandlers | handleGlobalEvent', () => {
expect(replay.recordingMode).toBe('buffer');
});
+ it('does not sample events whose stack trace only comes from the attachStacktrace default in buffer mode', () => {
+ const messageEvent: Event = {
+ message: 'Some message',
+ exception: { values: [{ value: 'Some message', stacktrace: { frames: [{ filename: 'app.js' }] } }] },
+ sdkProcessingMetadata: { hasDefaultSyntheticStacktrace: true },
+ };
+
+ expect(replay.recordingMode).toBe('buffer');
+ expect(handleGlobalEventListener(replay)(messageEvent, {})?.tags?.replayId).toBeUndefined();
+ expect(replay.session?.dirty).toBe(false);
+ });
+
it('does not skip non-rrweb errors', () => {
const errorEvent: Event = {
exception: {
diff --git a/packages/server-runtime-injection/package.json b/packages/server-runtime-injection/package.json
index b6285ac2060a..7fbbce3d0db6 100644
--- a/packages/server-runtime-injection/package.json
+++ b/packages/server-runtime-injection/package.json
@@ -49,8 +49,8 @@
"@sentry/server-utils": "11.10.0"
},
"devDependencies": {
- "@apm-js-collab/code-transformer": "^0.18.1",
- "@apm-js-collab/tracing-hooks": "^0.13.3",
+ "@apm-js-collab/code-transformer": "^0.19.0",
+ "@apm-js-collab/tracing-hooks": "^0.14.0",
"@sentry-internal/rollup-utils": "11.10.0",
"@types/node": "^20.19.0",
"@vercel/nft": "^1.11.0",
diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json
index c28fa0c1e68b..27d63ad8cec2 100644
--- a/packages/server-utils/package.json
+++ b/packages/server-utils/package.json
@@ -27,6 +27,11 @@
"import": "./build/esm/index.no-diagnostic-channels.js",
"require": "./build/cjs/index.no-diagnostic-channels.js"
},
+ "./request-handler": {
+ "types": "./build/types/index.request-handler.d.ts",
+ "import": "./build/esm/index.request-handler.js",
+ "require": "./build/cjs/index.request-handler.js"
+ },
"./orchestrion/config": {
"types": "./build/types/orchestrion/config/index.d.ts",
"import": "./build/esm/orchestrion/config/index.js",
@@ -97,17 +102,17 @@
},
"dependencies": {
"@opentelemetry/api": "^1.9.1",
- "@sentry/conventions": "^0.29.0",
+ "@sentry/conventions": "^0.30.0",
"@sentry/core": "11.10.0"
},
"devDependencies": {
- "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.4",
+ "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.5",
"@neondatabase/serverless": "1.2.0",
"@sentry-internal/rollup-utils": "11.10.0",
"@types/node": "^20.19.0",
"meriyah": "^6.1.4",
- "vite": "^8.3.1",
- "webpack": "^5.104.1"
+ "vite": "^8.3.5",
+ "webpack": "^5.112.2"
},
"scripts": {
"build": "run-p build:transpile build:types",
diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs
index ab0a51d3ef21..74f172ec5215 100644
--- a/packages/server-utils/rollup.npm.config.mjs
+++ b/packages/server-utils/rollup.npm.config.mjs
@@ -100,6 +100,7 @@ export default [
entrypoints: [
'src/index.ts',
'src/index.no-diagnostic-channels.ts',
+ 'src/index.request-handler.ts',
'src/orchestrion/config/index.ts',
'src/orchestrion/bundler/vite.ts',
'src/orchestrion/bundler/rollup.ts',
diff --git a/packages/server-utils/src/ai/flue/index.ts b/packages/server-utils/src/ai/flue/index.ts
index d4856f95f2ac..b5f969c8a16e 100644
--- a/packages/server-utils/src/ai/flue/index.ts
+++ b/packages/server-utils/src/ai/flue/index.ts
@@ -144,8 +144,8 @@ export function createFlueInstrumentation(options: FlueOptions = {}): FlueInstru
attributes: {
[SENTRY_ORIGIN]: FLUE_ORIGIN,
[GEN_AI_OPERATION_NAME]: 'invoke_agent',
- ...(ctx.agentName ? { [GEN_AI_AGENT_NAME]: ctx.agentName } : {}),
- ...(ctx.conversationId ? { [GEN_AI_CONVERSATION_ID]: ctx.conversationId } : {}),
+ [GEN_AI_AGENT_NAME]: ctx.agentName,
+ [GEN_AI_CONVERSATION_ID]: ctx.conversationId,
},
},
async (span: Span) => {
diff --git a/packages/server-utils/src/ai/flue/utils.ts b/packages/server-utils/src/ai/flue/utils.ts
index 7f22256bf319..399a2aec3580 100644
--- a/packages/server-utils/src/ai/flue/utils.ts
+++ b/packages/server-utils/src/ai/flue/utils.ts
@@ -123,10 +123,10 @@ export function startTurnSpan(observation: FlueObservation, turnSpans: SpanTrack
attributes: {
[SENTRY_ORIGIN]: FLUE_ORIGIN,
[GEN_AI_OPERATION_NAME]: 'chat',
- ...(observation.conversationId ? { [GEN_AI_CONVERSATION_ID]: observation.conversationId } : {}),
+ [GEN_AI_CONVERSATION_ID]: observation.conversationId,
// No conventional attribute for this; it is the only way to tell a compaction turn from a
// user-facing one.
- ...(observation.purpose ? { 'flue.turn.purpose': observation.purpose } : {}),
+ 'flue.turn.purpose': observation.purpose,
},
}),
);
@@ -207,8 +207,8 @@ export function startToolSpan(observation: FlueObservation, toolSpans: SpanTrack
attributes: {
[SENTRY_ORIGIN]: FLUE_ORIGIN,
[GEN_AI_OPERATION_NAME]: 'execute_tool',
- ...(toolName ? { [GEN_AI_TOOL_NAME]: toolName } : {}),
- ...(observation.conversationId ? { [GEN_AI_CONVERSATION_ID]: observation.conversationId } : {}),
+ [GEN_AI_TOOL_NAME]: toolName,
+ [GEN_AI_CONVERSATION_ID]: observation.conversationId,
...(recordInputs && observation.args !== undefined
? { [GEN_AI_TOOL_CALL_ARGUMENTS]: stringify(observation.args) }
: {}),
diff --git a/packages/server-utils/src/ai/pi-durable/index.ts b/packages/server-utils/src/ai/pi-durable/index.ts
index b7ee007e6a02..d2187aae394e 100644
--- a/packages/server-utils/src/ai/pi-durable/index.ts
+++ b/packages/server-utils/src/ai/pi-durable/index.ts
@@ -220,9 +220,9 @@ function runPhase(
const runInScope = async (scope: Scope): Promise => {
// Read at the first phase of a run, which is a `pi.tool` phase when a restarted Harness reruns a
- // tool call.
+ // tool call, and at each phase outside a run, such as an idle compaction or a task of an extension.
let sessionId = run?.sessionId;
- if (!sessionId && (run || kind === PI_TASK.COMPACTION)) {
+ if (!sessionId) {
sessionId = await readProviderSessionId(runtime, context);
if (run && sessionId) {
run.sessionId = sessionId;
diff --git a/packages/server-utils/src/ai/pi-durable/models.ts b/packages/server-utils/src/ai/pi-durable/models.ts
index b50a5cdb0420..196973159d70 100644
--- a/packages/server-utils/src/ai/pi-durable/models.ts
+++ b/packages/server-utils/src/ai/pi-durable/models.ts
@@ -117,14 +117,14 @@ function traceModelRequest(
attributes: {
[SENTRY_ORIGIN]: PI_DURABLE_ORIGIN,
[GEN_AI_OPERATION_NAME]: 'chat',
- ...(model.provider ? { [GEN_AI_PROVIDER_NAME]: model.provider } : {}),
- ...(model.id ? { [GEN_AI_REQUEST_MODEL]: model.id } : {}),
- ...(conversationId ? { [GEN_AI_CONVERSATION_ID]: conversationId } : {}),
+ [GEN_AI_PROVIDER_NAME]: model.provider,
+ [GEN_AI_REQUEST_MODEL]: model.id,
+ [GEN_AI_CONVERSATION_ID]: conversationId,
...getServerAttributes(model),
...(streaming ? { [GEN_AI_RESPONSE_STREAMING]: true } : {}),
- ...(callOptions?.temperature !== undefined ? { [GEN_AI_REQUEST_TEMPERATURE]: callOptions.temperature } : {}),
- ...(callOptions?.maxTokens !== undefined ? { [GEN_AI_REQUEST_MAX_TOKENS]: callOptions.maxTokens } : {}),
- ...(callOptions?.reasoning ? { [GEN_AI_REQUEST_REASONING_LEVEL]: callOptions.reasoning } : {}),
+ [GEN_AI_REQUEST_TEMPERATURE]: callOptions?.temperature,
+ [GEN_AI_REQUEST_MAX_TOKENS]: callOptions?.maxTokens,
+ [GEN_AI_REQUEST_REASONING_LEVEL]: callOptions?.reasoning,
...(recordInputs && context ? getRequestContentAttributes(context) : {}),
},
},
diff --git a/packages/server-utils/src/ai/pi-durable/tools.ts b/packages/server-utils/src/ai/pi-durable/tools.ts
index 8ccf21155549..6a6ced8f8787 100644
--- a/packages/server-utils/src/ai/pi-durable/tools.ts
+++ b/packages/server-utils/src/ai/pi-durable/tools.ts
@@ -122,8 +122,8 @@ export function instrumentTool(tool: PiTool, runs: PiRuns, options: GenAiOptions
[SENTRY_ORIGIN]: PI_DURABLE_ORIGIN,
[GEN_AI_OPERATION_NAME]: 'execute_tool',
[GEN_AI_TOOL_NAME]: tool.name,
- ...(tool.description ? { [GEN_AI_TOOL_DESCRIPTION]: tool.description } : {}),
- ...(api?.callId ? { [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: api.callId } : {}),
+ [GEN_AI_TOOL_DESCRIPTION]: tool.description,
+ [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: api?.callId,
...(api?.conversationId !== undefined
? { [GEN_AI_CONVERSATION_ID]: toSentryConversationId(runs, api.conversationId) }
: {}),
diff --git a/packages/server-utils/src/ai/typesafe/index.ts b/packages/server-utils/src/ai/typesafe/index.ts
index 65d24e6fc3f8..7bffea993239 100644
--- a/packages/server-utils/src/ai/typesafe/index.ts
+++ b/packages/server-utils/src/ai/typesafe/index.ts
@@ -50,7 +50,7 @@ function getRequestAttributes(
[SENTRY_ORIGIN]: TYPESAFE_ORIGIN,
[GEN_AI_OPERATION_NAME]: 'decide',
[GEN_AI_PROVIDER_NAME]: TYPESAFE_PROVIDER_NAME,
- ...(model ? { [GEN_AI_REQUEST_MODEL]: model } : {}),
+ [GEN_AI_REQUEST_MODEL]: model,
...(recordInputs ? { [GEN_AI_INPUT_MESSAGES]: getEvaluationInputMessages(request) } : {}),
};
}
diff --git a/packages/server-utils/src/exports.ts b/packages/server-utils/src/exports.ts
index 799ed7c69d18..7b36665f97cd 100644
--- a/packages/server-utils/src/exports.ts
+++ b/packages/server-utils/src/exports.ts
@@ -1,19 +1,17 @@
// 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';
-export { getSqlQuerySummary, sanitizeSqlQuery, sanitizeSqlQueryWithSummary } from './utils/sql';
+export { getSqlQuerySummary, sanitizeSqlQuery, getSqlQueryAttributes, getSqlSpanName } from './utils/sql';
export type { SqlDialect } from './utils/sql';
-export { instrumentPostgresJsSql } from './integrations/postgresjs';
-export type { PostgresConnectionContext } from './integrations/postgresjs';
+export { instrumentPostgresJsSql } from './integrations/postgresjs-wrapper';
+export type { PostgresConnectionContext } from './integrations/postgresjs-wrapper';
// Shared, runtime-agnostic Hono instrumentation re-used by the `@sentry/hono` SDK across all runtimes.
// Sourced directly from their modules (not the `./integrations/hono` barrel, which also exports the
// diagnostics-channel-based `honoIntegration`) so the non-Node adapters can import them from the
-// Node-free `@sentry/server-utils/no-diagnostic-channels` entry.
+// diagnostics_channel-free `@sentry/server-utils/no-diagnostic-channels` entry.
export { applyHonoPatches, earlyPatchHono } from './integrations/hono/applyPatches';
export { createHonoRequestMiddleware } from './integrations/hono/createHonoMiddleware';
export type { CreateHonoRequestMiddlewareOptions } from './integrations/hono/createHonoMiddleware';
diff --git a/packages/server-utils/src/index.request-handler.ts b/packages/server-utils/src/index.request-handler.ts
new file mode 100644
index 000000000000..73e0902cc088
--- /dev/null
+++ b/packages/server-utils/src/index.request-handler.ts
@@ -0,0 +1,4 @@
+// The web `Request` handler wrapper, on its own entry with no Node.js imports, so Cloudflare can use
+// it on workers without the `nodejs_compat` compatibility flag.
+export { wrapServerRequestHandler } from './utils/wrapServerRequestHandler';
+export type { ServerRequestHandlerConfig } from './utils/wrapServerRequestHandler';
diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts
index dacf7ff16b7f..74e4dd25e64a 100644
--- a/packages/server-utils/src/index.ts
+++ b/packages/server-utils/src/index.ts
@@ -38,6 +38,12 @@ export { anthropicAIIntegration } from './integrations/anthropic';
export { awsIntegration } from './integrations/aws-sdk';
export { dataloaderIntegration } from './integrations/dataloader';
export { genericPoolIntegration } from './integrations/generic-pool';
+export { cronJobIntegration } from './integrations/cron';
+export type { CronJobIntegrationOptions } from './integrations/cron';
+export { nodeCronIntegration } from './integrations/node-cron';
+export type { NodeCronIntegrationOptions } from './integrations/node-cron';
+export { nodeScheduleIntegration } from './integrations/node-schedule';
+export type { NodeScheduleIntegrationOptions } from './integrations/node-schedule';
export { googleGenAIIntegration } from './integrations/google-genai';
export { graphqlIntegration } from './integrations/graphql';
export { hapiIntegration } from './integrations/hapi';
@@ -66,8 +72,8 @@ export { typesafeIntegration } from './integrations/typesafe';
export { mysqlIntegration } from './integrations/mysql';
export { mysql2Integration } from './integrations/mysql2';
export { openAIIntegration } from './integrations/openai';
-export { postgresIntegration } from './integrations/postgres';
-export { postgresJsIntegration } from './integrations/postgres-js';
+export { postgresIntegration } from './integrations/pg';
+export { postgresJsIntegration } from './integrations/postgresjs';
export { neonIntegration } from './integrations/neon';
export { tediousIntegration } from './integrations/tedious';
export { vercelAIIntegration } from './integrations/vercel-ai';
diff --git a/packages/server-utils/src/integrations/amqplib.ts b/packages/server-utils/src/integrations/amqplib.ts
index f5db922608a8..dcd0346cac1e 100644
--- a/packages/server-utils/src/integrations/amqplib.ts
+++ b/packages/server-utils/src/integrations/amqplib.ts
@@ -26,6 +26,8 @@ import {
SERVER_PORT,
URL_FULL,
SENTRY_ORIGIN,
+ MESSAGING_MESSAGE_CONVERSATION_ID,
+ MESSAGING_RABBITMQ_DESTINATION_ROUTING_KEY,
} from '@sentry/conventions/attributes';
import { QUEUE_PROCESS, QUEUE_PUBLISH } from '@sentry/conventions/op';
import type { OrchestrionChannelContext } from '../orchestrion/types';
@@ -41,9 +43,6 @@ const INTEGRATION_NAME = 'Amqplib' as const;
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';
-
const MESSAGING_OPERATION_VALUE_PROCESS = 'process';
const MESSAGING_OPERATION_VALUE_SEND = 'send';
@@ -498,11 +497,11 @@ function startPublishSpan(data: AmqpChannelContext): Span {
[SENTRY_KIND]: 'producer',
...getStoredConnectionAttributes(data.self),
[MESSAGING_DESTINATION_NAME]: destination,
- [ATTR_MESSAGING_RABBITMQ_DESTINATION_ROUTING_KEY]: routingKey,
+ [MESSAGING_RABBITMQ_DESTINATION_ROUTING_KEY]: routingKey,
[MESSAGING_OPERATION_NAME]: MESSAGING_OPERATION_VALUE_SEND,
[MESSAGING_OPERATION_TYPE]: MESSAGING_OPERATION_VALUE_SEND,
[MESSAGING_MESSAGE_ID]: options?.messageId as string | undefined,
- [ATTR_MESSAGING_CONVERSATION_ID]: options?.correlationId as string | undefined,
+ [MESSAGING_MESSAGE_CONVERSATION_ID]: options?.correlationId as string | undefined,
[SENTRY_ORIGIN]: PUBLISHER_ORIGIN,
},
});
@@ -544,11 +543,11 @@ function startConsumeSpan(queue: string | undefined, msg: ConsumeMessage, channe
[SENTRY_SEGMENT_NAME_SOURCE]: 'component',
...getStoredConnectionAttributes(channel),
[MESSAGING_DESTINATION_NAME]: destination,
- [ATTR_MESSAGING_RABBITMQ_DESTINATION_ROUTING_KEY]: msg.fields?.routingKey,
+ [MESSAGING_RABBITMQ_DESTINATION_ROUTING_KEY]: msg.fields?.routingKey,
[MESSAGING_OPERATION_NAME]: MESSAGING_OPERATION_VALUE_PROCESS,
[MESSAGING_OPERATION_TYPE]: MESSAGING_OPERATION_VALUE_PROCESS,
[MESSAGING_MESSAGE_ID]: msg.properties?.messageId as string | undefined,
- [ATTR_MESSAGING_CONVERSATION_ID]: msg.properties?.correlationId as string | undefined,
+ [MESSAGING_MESSAGE_CONVERSATION_ID]: msg.properties?.correlationId as string | undefined,
[SENTRY_ORIGIN]: CONSUMER_ORIGIN,
},
});
diff --git a/packages/server-utils/src/integrations/anthropic.ts b/packages/server-utils/src/integrations/anthropic.ts
index 044b2d09ff43..c39ebcdfdeb2 100644
--- a/packages/server-utils/src/integrations/anthropic.ts
+++ b/packages/server-utils/src/integrations/anthropic.ts
@@ -47,7 +47,19 @@ const _anthropicAIIntegration = ((options: AnthropicAiOptions = {}) => {
};
}) satisfies IntegrationFn;
+// Since 0.134 `@anthropic-ai/sdk` traces itself through the global OTel tracer provider, which duplicates our
+// spans. Only turned off once our channels subscribe, so an app we do not instrument keeps the SDK's spans.
+// A value the user set wins.
+function disableNativeTracing(): void {
+ try {
+ process.env.ANTHROPIC_OPEN_TELEMETRY ??= 'false';
+ } catch {
+ // `process` is absent (edge runtimes) or env access is gated (Deno without `--allow-env`).
+ }
+}
+
function instrumentAnthropic(options: AnthropicAiOptions): void {
+ disableNativeTracing();
for (const { channel, operation, stream } of INSTRUMENTED_CHANNELS) {
bindTracingChannelToSpan(
diagnosticsChannel.tracingChannel(channel),
diff --git a/packages/server-utils/src/integrations/cron.ts b/packages/server-utils/src/integrations/cron.ts
new file mode 100644
index 000000000000..8a08c2fede4f
--- /dev/null
+++ b/packages/server-utils/src/integrations/cron.ts
@@ -0,0 +1,71 @@
+import type { IntegrationFn, MonitorConfig } from '@sentry/core';
+import { defineIntegration } from '@sentry/core';
+import { CHANNELS } from '../orchestrion/channels';
+import { cronModuleNames } from '../orchestrion/config/cron';
+import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation';
+import type { AnyFn, ScheduleChange } from '../utils/cronMonitoring';
+import {
+ cronExpressionToMonitorConfig,
+ shouldMonitorJob,
+ subscribeToJobCreation,
+ subscribeToScheduleChange,
+ updateMonitorConfig,
+ wrapScheduledJob,
+} from '../utils/cronMonitoring';
+
+const INTEGRATION_NAME = 'CronJob' as const;
+
+export interface CronJobIntegrationOptions {
+ /** Job names not to monitor. Strings must match the name exactly. */
+ excludeJobs?: Array;
+}
+
+const _cronJobIntegration = ((options: CronJobIntegrationOptions = {}) => {
+ return {
+ name: INTEGRATION_NAME,
+ setup(client) {
+ invokeOrchestrionInstrumentation(client, cronModuleNames, subscribe, [options], {
+ requiresTracingChannelBinding: false,
+ });
+ },
+ };
+}) satisfies IntegrationFn;
+
+/**
+ * Sends Crons check-ins for every `CronJob` of the `cron` package (4.3+) that has a `name`.
+ * The monitor slug is the job name. Jobs without a name, one-off jobs, and jobs with a schedule Crons can't express
+ * are not monitored.
+ */
+export const cronJobIntegration = defineIntegration(_cronJobIntegration);
+
+function subscribe(options: CronJobIntegrationOptions): void {
+ subscribeToJobCreation(CHANNELS.CRON_JOB_CONSTRUCTOR, INTEGRATION_NAME, args => wrapJob(args, options));
+ subscribeToScheduleChange(CHANNELS.CRON_JOB_SET_TIME, INTEGRATION_NAME, onSetTime);
+}
+
+/** Sets the new time's config after `job.setTime(cronTime)`. Without one, the job doesn't check in. */
+export function onSetTime({ arguments: [cronTime], self }: ScheduleChange): void {
+ const job = self as { cronTime?: unknown; _callbacks?: unknown[] } | undefined;
+ // `setTime` throws and keeps the old time when it isn't given a `CronTime`.
+ if (!job || job.cronTime !== cronTime) {
+ return;
+ }
+ const { source, timeZone, utcOffset } = cronTime as { source?: unknown; timeZone?: unknown; utcOffset?: unknown };
+ const monitorConfig: MonitorConfig | undefined =
+ typeof source === 'string' && utcOffset == null ? cronExpressionToMonitorConfig(source, timeZone, 'or') : undefined;
+ for (const callback of job._callbacks || []) {
+ updateMonitorConfig(callback, monitorConfig);
+ }
+}
+
+// `new CronJob(cronTime, onTick, onComplete, start, timeZone, context, runOnInit, utcOffset, ..., name)`
+function wrapJob(args: unknown[], options: CronJobIntegrationOptions): void {
+ const [cronTime, onTick, , , timeZone] = args;
+ const utcOffset = args[7];
+ const name = args[11];
+ if (typeof cronTime !== 'string' || !shouldMonitorJob(name, onTick, options.excludeJobs)) {
+ return;
+ }
+ const monitorConfig = utcOffset == null ? cronExpressionToMonitorConfig(cronTime, timeZone, 'or') : undefined;
+ args[1] = wrapScheduledJob(onTick as AnyFn, name, monitorConfig, 'auto.function.cron');
+}
diff --git a/packages/server-utils/src/integrations/dataloader.ts b/packages/server-utils/src/integrations/dataloader.ts
index 5fb50a577325..63983e9b32b3 100644
--- a/packages/server-utils/src/integrations/dataloader.ts
+++ b/packages/server-utils/src/integrations/dataloader.ts
@@ -179,9 +179,7 @@ function subscribeConstruct(): void {
function subscribeLoad(): void {
const channel = diagnosticsChannel.tracingChannel(CHANNELS.DATALOADER_LOAD);
- bindTracingChannelToSpan(channel, data => startInactiveSpanFor(data.self, 'load', data.arguments[0]), {
- requiresParentSpan: true,
- });
+ bindTracingChannelToSpan(channel, data => startInactiveSpanFor(data.self, 'load', data.arguments[0]));
channel.end.subscribe(message => {
const data = message as TracingChannelPayloadWithSpan;
@@ -194,10 +192,8 @@ function subscribeLoad(): void {
}
function subscribeSimpleOperation(channelName: ChannelName, operation: Operation): void {
- bindTracingChannelToSpan(
- diagnosticsChannel.tracingChannel(channelName),
- data => startInactiveSpanFor(data.self, operation, data.arguments[0]),
- { requiresParentSpan: true },
+ bindTracingChannelToSpan(diagnosticsChannel.tracingChannel(channelName), data =>
+ startInactiveSpanFor(data.self, operation, data.arguments[0]),
);
}
diff --git a/packages/server-utils/src/integrations/express/instrumentation.ts b/packages/server-utils/src/integrations/express/instrumentation.ts
index a3d079c6bd2b..cac3ede931ff 100644
--- a/packages/server-utils/src/integrations/express/instrumentation.ts
+++ b/packages/server-utils/src/integrations/express/instrumentation.ts
@@ -344,7 +344,7 @@ function getSpanForLayer(data: HandleChannelContext, options: ExpressIntegration
[SENTRY_OP]: EXPRESS_TYPE_TO_SPAN_OP[type],
[ATTR_EXPRESS_NAME]: name,
[ATTR_EXPRESS_TYPE]: type,
- ...(matchedRoute ? { [HTTP_ROUTE]: matchedRoute } : {}),
+ [HTTP_ROUTE]: matchedRoute,
},
});
diff --git a/packages/server-utils/src/integrations/firebase/firestore.ts b/packages/server-utils/src/integrations/firebase/firestore.ts
index 155e07797d1a..3154e51869be 100644
--- a/packages/server-utils/src/integrations/firebase/firestore.ts
+++ b/packages/server-utils/src/integrations/firebase/firestore.ts
@@ -22,6 +22,7 @@ import type { FirebaseApp, FirebaseOptions, FirestoreReference, FirestoreSetting
export function startFirestoreSpan(spanName: string, reference: FirestoreReference): Span {
return startInactiveSpan({
name: `${spanName} ${reference.path}`,
+ onlyIfParent: true,
attributes: {
[SENTRY_OP]: DB_QUERY,
[SENTRY_KIND]: 'client',
diff --git a/packages/server-utils/src/integrations/graphql/constants.ts b/packages/server-utils/src/integrations/graphql/constants.ts
index 7a6f556d0c6d..10e8ff72a670 100644
--- a/packages/server-utils/src/integrations/graphql/constants.ts
+++ b/packages/server-utils/src/integrations/graphql/constants.ts
@@ -9,8 +9,7 @@ export const SPAN_NAME_EXECUTE = 'graphql.execute';
export const SPAN_NAME_SUBSCRIBE = 'graphql.subscribe';
export const SPAN_NAME_RESOLVE = 'graphql.resolve';
-// Inlined until `@sentry/conventions` ships it (https://github.com/getsentry/sentry-conventions/pull/572).
-export const GRAPHQL_PROCESSING_TYPE = 'graphql.processing.type';
+export { GRAPHQL_PROCESSING_TYPE } from '@sentry/conventions/attributes';
export const PROCESSING_TYPE_PARSE = 'parse';
export const PROCESSING_TYPE_VALIDATE = 'validate';
diff --git a/packages/server-utils/src/integrations/hono/honoIntegration.ts b/packages/server-utils/src/integrations/hono/honoIntegration.ts
index 575462d6d018..d5ef285cdf62 100644
--- a/packages/server-utils/src/integrations/hono/honoIntegration.ts
+++ b/packages/server-utils/src/integrations/hono/honoIntegration.ts
@@ -111,13 +111,13 @@ function instrumentInternalRequests(): void {
).toUpperCase();
return startInactiveSpan({
name: `${method} ${extractPathname(input)}`,
+ onlyIfParent: true,
attributes: {
[SENTRY_OP]: HTTP_SERVER,
[SENTRY_ORIGIN]: INTERNAL_REQUEST_ORIGIN,
},
});
},
- { requiresParentSpan: true },
);
}
diff --git a/packages/server-utils/src/integrations/index.ts b/packages/server-utils/src/integrations/index.ts
index 9643f95250ff..e70488a63351 100644
--- a/packages/server-utils/src/integrations/index.ts
+++ b/packages/server-utils/src/integrations/index.ts
@@ -6,7 +6,7 @@ import { graphqlIntegration } from './graphql';
import { redisIntegration } from './redis';
import { mysqlIntegration } from './mysql';
import { mysql2Integration } from './mysql2';
-import { postgresIntegration } from './postgres';
+import { postgresIntegration } from './pg';
import { prismaIntegration } from './prisma';
import { tediousIntegration } from './tedious';
import { genericPoolIntegration } from './generic-pool';
@@ -26,7 +26,7 @@ import { mistralAIIntegration } from './mistral';
import { groqIntegration } from './groq';
import { togetherAIIntegration } from './together-ai';
import { typesafeIntegration } from './typesafe';
-import { postgresJsIntegration } from './postgres-js';
+import { postgresJsIntegration } from './postgresjs';
import { neonIntegration } from './neon';
import { firebaseIntegration } from './firebase';
import { expressIntegration } from './express';
diff --git a/packages/server-utils/src/integrations/kafkajs/semconv.ts b/packages/server-utils/src/integrations/kafkajs/semconv.ts
index 64375bee5548..3dc404fe6145 100644
--- a/packages/server-utils/src/integrations/kafkajs/semconv.ts
+++ b/packages/server-utils/src/integrations/kafkajs/semconv.ts
@@ -1,13 +1,10 @@
-/*
- * Unstable OTel semantic-convention keys/values without a `@sentry/conventions` constant, inlined to
- * keep this integration free of `@opentelemetry/*` deps. Values are byte-identical to the vendored OTel
- * instrumentation (`@sentry/node`'s `integrations/tracing/kafka/vendored/semconv.ts`) for span parity.
- */
+export {
+ MESSAGING_DESTINATION_PARTITION_ID as ATTR_MESSAGING_DESTINATION_PARTITION_ID,
+ MESSAGING_KAFKA_MESSAGE_KEY as ATTR_MESSAGING_KAFKA_MESSAGE_KEY,
+ MESSAGING_KAFKA_MESSAGE_TOMBSTONE as ATTR_MESSAGING_KAFKA_MESSAGE_TOMBSTONE,
+ MESSAGING_KAFKA_OFFSET as ATTR_MESSAGING_KAFKA_OFFSET,
+} from '@sentry/conventions/attributes';
-export const ATTR_MESSAGING_DESTINATION_PARTITION_ID = 'messaging.destination.partition.id' as const;
-export const ATTR_MESSAGING_KAFKA_MESSAGE_KEY = 'messaging.kafka.message.key' as const;
-export const ATTR_MESSAGING_KAFKA_MESSAGE_TOMBSTONE = 'messaging.kafka.message.tombstone' as const;
-export const ATTR_MESSAGING_KAFKA_OFFSET = 'messaging.kafka.offset' as const;
export const MESSAGING_OPERATION_TYPE_VALUE_PROCESS = 'process' as const;
export const MESSAGING_OPERATION_TYPE_VALUE_RECEIVE = 'receive' as const;
export const MESSAGING_OPERATION_TYPE_VALUE_SEND = 'send' as const;
diff --git a/packages/server-utils/src/integrations/knex.ts b/packages/server-utils/src/integrations/knex.ts
index bdc3d4dac16f..ed1b36ba4f80 100644
--- a/packages/server-utils/src/integrations/knex.ts
+++ b/packages/server-utils/src/integrations/knex.ts
@@ -8,8 +8,6 @@ import {
debug,
defineIntegration,
getActiveSpan,
- getClient,
- hasSpanStreamingEnabled,
SPAN_STATUS_ERROR,
startInactiveSpan,
truncate,
@@ -18,8 +16,6 @@ import {
import {
DB_NAMESPACE,
DB_OPERATION_NAME,
- DB_QUERY_SUMMARY,
- DB_QUERY_TEXT,
DB_SYSTEM_NAME,
DB_USER,
NETWORK_TRANSPORT,
@@ -34,7 +30,7 @@ import { DEBUG_BUILD } from '../debug-build';
import type { OrchestrionChannelContext } from '../orchestrion/types';
import { CHANNELS } from '../orchestrion/channels';
import { bindTracingChannelToSpan } from '../tracing-channel';
-import { sanitizeSqlQueryWithSummary, toSqlDialect } from '../utils/sql';
+import { getSqlQueryAttributes, getSqlSpanName, toSqlDialect } from '../utils/sql';
// NOTE: this uses the same name as the OTel integration by design. `@sentry/node`'s `knexIntegration`
// picks this subscriber over the vendored OTel path when orchestrion injection is active.
@@ -165,14 +161,11 @@ function subscribeQuery(): void {
const dbSystem = mapSystem(client?.driverName);
const dialect = toSqlDialect(client?.driverName);
- const { queryText: dbStatement, querySummary } = sanitizeSqlQueryWithSummary(
- query?.sql ? truncate(query.sql, MAX_QUERY_LENGTH) : undefined,
- dialect,
- );
const attributes: SpanAttributes = {
[SENTRY_OP]: DB,
[SENTRY_KIND]: 'client',
[SENTRY_ORIGIN]: ORIGIN,
+ ...getSqlQueryAttributes(query?.sql ? truncate(query.sql, MAX_QUERY_LENGTH) : undefined, dialect),
'knex.version': data.moduleVersion,
[DB_SYSTEM_NAME]: dbSystem,
[ATTR_DB_SQL_TABLE]: table,
@@ -182,18 +175,10 @@ function subscribeQuery(): void {
[SERVER_ADDRESS]: connection?.host ?? extractHostFromConnectionString(connectionString),
[SERVER_PORT]: connection?.port ?? extractPortFromConnectionString(connectionString),
[NETWORK_TRANSPORT]: connection?.filename === ':memory:' ? 'inproc' : undefined,
- [DB_QUERY_TEXT]: dbStatement,
- [DB_QUERY_SUMMARY]: querySummary,
};
- const sentryClient = getClient();
- const spanName =
- sentryClient && hasSpanStreamingEnabled(sentryClient)
- ? querySummary || getSecondaryStreamName(dbSystem, dbNameSpace, operation, table)
- : (dbStatement ?? getName(dbNameSpace, operation, table) ?? 'knex.query');
-
return startInactiveSpan({
- name: spanName,
+ name: getSqlSpanName(attributes, 'knex.query'),
parentSpan,
attributes,
});
@@ -262,40 +247,6 @@ function mapSystem(driverName: string | undefined): string | undefined {
return driverName;
}
-function getName(db: string | undefined, operation?: string, table?: string): string | undefined {
- // `db` can be undefined when the database can't be determined from the knex config. Guard against
- // interpolating it (which would emit the literal "undefined") and let the call site fall back.
- if (operation && db) {
- return table ? `${operation} ${db}.${table}` : `${operation} ${db}`;
- }
- return db;
-}
-
-function getSecondaryStreamName(
- dbSystem: string | undefined,
- dbNameSpace: string | undefined,
- operation?: string,
- table?: string,
-): string {
- if (operation) {
- if (table) {
- return `${operation} ${table}`;
- }
- if (dbNameSpace) {
- return `${operation} ${dbNameSpace}`;
- }
- }
- if (table) {
- return table;
- }
- if (dbNameSpace) {
- return dbNameSpace;
- }
- // Mirrors the postgres integration, which falls back to `{db.system.name}` rather than to a static
- // name. `db.system.name` is only unset when the knex client reports no driver.
- return dbSystem ?? 'knex.query';
-}
-
function extractTableName(builder: KnexBuilder | undefined): string | undefined {
const table = builder?._single?.table;
if (table && typeof table === 'object') {
diff --git a/packages/server-utils/src/integrations/mongodb/index.ts b/packages/server-utils/src/integrations/mongodb/index.ts
index 593638025c6f..b27a3c46c933 100644
--- a/packages/server-utils/src/integrations/mongodb/index.ts
+++ b/packages/server-utils/src/integrations/mongodb/index.ts
@@ -64,23 +64,17 @@ function instrumentMongodb(): void {
* (both publish to this channel).
*/
function subscribeV4Command(): void {
- bindTracingChannelToSpan(
- diagnosticsChannel.tracingChannel(CHANNELS.MONGODB_COMMAND),
- data => {
- const args = data.arguments ?? [];
- const ns = args[0] as MongodbNamespace | undefined;
- const cmd = args[1] as Record | undefined;
- // Skip handshake/heartbeat commands (matches otel).
- if (!ns || !cmd || typeof cmd !== 'object' || cmd.ismaster || cmd.hello) {
- return undefined;
- }
- const operation = Object.keys(cmd)[0];
- return startMongoSpan(getV4SpanAttributes(data.self, ns, cmd, operation, ORIGIN));
- },
- // Matches otel's `shouldSkipInstrumentation`: only trace when there is
- // an active parent span, to avoid emitting orphaned mongodb spans.
- { requiresParentSpan: true },
- );
+ bindTracingChannelToSpan(diagnosticsChannel.tracingChannel(CHANNELS.MONGODB_COMMAND), data => {
+ const args = data.arguments ?? [];
+ const ns = args[0] as MongodbNamespace | undefined;
+ const cmd = args[1] as Record | undefined;
+ // Skip handshake/heartbeat commands (matches otel).
+ if (!ns || !cmd || typeof cmd !== 'object' || cmd.ismaster || cmd.hello) {
+ return undefined;
+ }
+ const operation = Object.keys(cmd)[0];
+ return startMongoSpan(getV4SpanAttributes(data.self, ns, cmd, operation, ORIGIN));
+ });
}
/**
@@ -150,21 +144,17 @@ function subscribeV3Wireprotocol(): void {
}
function bindV3(channelName: string, extract: (args: unknown[]) => V3CallInfo | undefined): void {
- bindTracingChannelToSpan(
- diagnosticsChannel.tracingChannel(channelName),
- data => {
- const args = data.arguments;
- if (!args) {
- return undefined;
- }
- const info = extract(args);
- if (!info || typeof info.ns !== 'string') {
- return undefined;
- }
- return startMongoSpan(getV3SpanAttributes(info.ns, info.topology, info.command, info.operation, ORIGIN));
- },
- { requiresParentSpan: true },
- );
+ bindTracingChannelToSpan(diagnosticsChannel.tracingChannel(channelName), data => {
+ const args = data.arguments;
+ if (!args) {
+ return undefined;
+ }
+ const info = extract(args);
+ if (!info || typeof info.ns !== 'string') {
+ return undefined;
+ }
+ return startMongoSpan(getV3SpanAttributes(info.ns, info.topology, info.command, info.operation, ORIGIN));
+ });
}
/**
diff --git a/packages/server-utils/src/integrations/mongodb/mongodb-span.ts b/packages/server-utils/src/integrations/mongodb/mongodb-span.ts
index 2f0a86721314..7c243081295c 100644
--- a/packages/server-utils/src/integrations/mongodb/mongodb-span.ts
+++ b/packages/server-utils/src/integrations/mongodb/mongodb-span.ts
@@ -9,14 +9,12 @@ import {
SERVER_ADDRESS,
SERVER_PORT,
SENTRY_ORIGIN,
+ DB_CONNECTION_STRING,
} from '@sentry/conventions/attributes';
import { DB } from '@sentry/conventions/op';
import type { Span, SpanAttributes } from '@sentry/core';
import { getClient, hasSpanStreamingEnabled, isObjectLike, startInactiveSpan } from '@sentry/core';
-// `db.connection_string` is not part of `@sentry/conventions`, so it stays inlined to match
-// what `@opentelemetry/instrumentation-mongodb` emitted.
-const ATTR_DB_CONNECTION_STRING = 'db.connection_string';
const DB_SYSTEM_VALUE_MONGODB = 'mongodb';
/**
@@ -127,7 +125,8 @@ export function getSpanAttributes(
[DB_NAMESPACE]: dbName,
[DB_COLLECTION_NAME]: dbCollection,
[DB_OPERATION_NAME]: operation,
- [ATTR_DB_CONNECTION_STRING]: `mongodb://${host}:${port}/${dbName}`,
+ // oxlint-disable-next-line typescript/no-deprecated -- Preserve the existing attribute name.
+ [DB_CONNECTION_STRING]: `mongodb://${host}:${port}/${dbName}`,
};
if (host && port) {
@@ -236,6 +235,7 @@ export function startMongoSpan(attributes: SpanAttributes): Span {
return startInactiveSpan({
name,
+ onlyIfParent: true,
attributes: {
[SENTRY_OP]: DB,
[SENTRY_KIND]: 'client',
diff --git a/packages/server-utils/src/integrations/mysql.ts b/packages/server-utils/src/integrations/mysql.ts
index d3e8010cb1ba..32c2f6809b36 100644
--- a/packages/server-utils/src/integrations/mysql.ts
+++ b/packages/server-utils/src/integrations/mysql.ts
@@ -1,8 +1,6 @@
import * as diagnosticsChannel from '../utils/diagnosticsChannel';
import {
DB_NAMESPACE,
- DB_QUERY_SUMMARY,
- DB_QUERY_TEXT,
DB_SYSTEM_NAME,
DB_USER,
SENTRY_KIND,
@@ -10,19 +8,12 @@ import {
SERVER_ADDRESS,
SERVER_PORT,
SENTRY_ORIGIN,
+ DB_CONNECTION_STRING,
} from '@sentry/conventions/attributes';
import { DB } from '@sentry/conventions/op';
-import type { IntegrationFn, Scope } from '@sentry/core';
-import {
- isObjectLike,
- bindScopeToEmitter,
- defineIntegration,
- getClient,
- getCurrentScope,
- hasSpanStreamingEnabled,
- startInactiveSpan,
-} from '@sentry/core';
-import { sanitizeSqlQueryWithSummary } from '../utils/sql';
+import type { IntegrationFn, Scope, SpanAttributes } from '@sentry/core';
+import { isObjectLike, bindScopeToEmitter, defineIntegration, getCurrentScope, startInactiveSpan } from '@sentry/core';
+import { getSqlQueryAttributes, getSqlSpanName } from '../utils/sql';
import type { OrchestrionChannelContext } from '../orchestrion/types';
import { CHANNELS } from '../orchestrion/channels';
import { bindTracingChannelToSpan } from '../tracing-channel';
@@ -33,10 +24,6 @@ import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation
// When enabled, OTel 'Mysql' integration is omitted from the default set.
const INTEGRATION_NAME = 'Mysql' as const;
-// `db.connection_string` is not part of `@sentry/conventions`, so it stays inlined. Matches
-// `@opentelemetry/instrumentation-mysql`'s default shape.
-const ATTR_DB_CONNECTION_STRING = 'db.connection_string';
-
const DB_SYSTEM_NAME_VALUE_MYSQL = 'mysql' as const;
/**
@@ -84,30 +71,21 @@ function instrumentMysql(): void {
// handler with the caller's context lost. `deferSpanEnd` replays this scope onto the emitter.
data._sentryCallerScope = getCurrentScope();
- const { queryText, querySummary } = sanitizeSqlQueryWithSummary(sql, 'mysql');
-
- const client = getClient();
- const name =
- client && hasSpanStreamingEnabled(client)
- ? querySummary || database || DB_SYSTEM_NAME_VALUE_MYSQL
- : (queryText ?? 'mysql.query');
-
- return startInactiveSpan({
- name,
- attributes: {
- [SENTRY_OP]: DB,
- [SENTRY_KIND]: 'client',
- [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_MYSQL,
- [SENTRY_ORIGIN]: 'auto.db.mysql',
- [ATTR_DB_CONNECTION_STRING]: getJDBCString(host, portIsNumber ? portNumber : undefined, database),
- ...(database ? { [DB_NAMESPACE]: database } : {}),
- ...(user ? { [DB_USER]: user } : {}),
- ...(queryText ? { [DB_QUERY_TEXT]: queryText } : {}),
- [DB_QUERY_SUMMARY]: querySummary,
- [SERVER_ADDRESS]: host,
- [SERVER_PORT]: portIsNumber ? portNumber : undefined,
- },
- });
+ const attributes: SpanAttributes = {
+ [SENTRY_OP]: DB,
+ [SENTRY_KIND]: 'client',
+ [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_MYSQL,
+ [SENTRY_ORIGIN]: 'auto.db.mysql',
+ // oxlint-disable-next-line typescript/no-deprecated -- Preserve the existing attribute name.
+ [DB_CONNECTION_STRING]: getJDBCString(host, portIsNumber ? portNumber : undefined, database),
+ [DB_NAMESPACE]: database,
+ [DB_USER]: user,
+ ...getSqlQueryAttributes(sql, 'mysql'),
+ [SERVER_ADDRESS]: host,
+ [SERVER_PORT]: portIsNumber ? portNumber : undefined,
+ };
+
+ return startInactiveSpan({ name: getSqlSpanName(attributes, 'mysql.query'), attributes });
},
{
// No-callback `query(sql)` returns a streamable `Query` emitter as `result`; it settles on the
diff --git a/packages/server-utils/src/integrations/mysql2/index.ts b/packages/server-utils/src/integrations/mysql2/index.ts
index 5f337d21a41d..7fde6937a09c 100644
--- a/packages/server-utils/src/integrations/mysql2/index.ts
+++ b/packages/server-utils/src/integrations/mysql2/index.ts
@@ -1,14 +1,7 @@
import * as diagnosticsChannel from '../../utils/diagnosticsChannel';
import type { IntegrationFn, SpanAttributes } from '@sentry/core';
-import {
- defineIntegration,
- getClient,
- hasSpanStreamingEnabled,
- isObjectLike,
- startInactiveSpan,
- waitForTracingChannelBinding,
-} from '@sentry/core';
-import { sanitizeSqlQueryWithSummary } from '../../utils/sql';
+import { defineIntegration, isObjectLike, startInactiveSpan, waitForTracingChannelBinding } from '@sentry/core';
+import { getSqlQueryAttributes, getSqlSpanName } from '../../utils/sql';
import { subscribeMysql2DiagnosticChannels } from './mysql2-dc-subscriber';
import type { OrchestrionChannelContext } from '../../orchestrion/types';
import type { ChannelName } from '../../orchestrion/channels';
@@ -18,8 +11,6 @@ import { mysql2ModuleNames } from '../../orchestrion/config/mysql2';
import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation';
import {
DB_NAMESPACE,
- DB_QUERY_SUMMARY,
- DB_QUERY_TEXT,
DB_SYSTEM_NAME,
DB_USER,
SENTRY_KIND,
@@ -74,34 +65,19 @@ function instrumentMysql2Orchestrion(): void {
}
function subscribeQueryChannel(channelName: ChannelName): void {
- bindTracingChannelToSpan(
- diagnosticsChannel.tracingChannel(channelName),
- data => {
- const statement = getQueryText(data.arguments);
- const connectionAttributes = getConnectionAttributes(data.self?.config);
- const { queryText, querySummary } = sanitizeSqlQueryWithSummary(statement, 'mysql');
-
- const client = getClient();
- const name =
- client && hasSpanStreamingEnabled(client)
- ? querySummary || (connectionAttributes[DB_NAMESPACE] as string | undefined) || DB_SYSTEM_VALUE_MYSQL
- : (queryText ?? 'mysql2.query');
+ bindTracingChannelToSpan(diagnosticsChannel.tracingChannel(channelName), data => {
+ const statement = getQueryText(data.arguments);
+ const attributes: SpanAttributes = {
+ [SENTRY_KIND]: 'client',
+ [SENTRY_ORIGIN]: ORIGIN,
+ [SENTRY_OP]: DB,
+ [DB_SYSTEM_NAME]: DB_SYSTEM_VALUE_MYSQL,
+ ...getSqlQueryAttributes(statement, 'mysql'),
+ ...getConnectionAttributes(data.self?.config),
+ };
- return startInactiveSpan({
- name,
- attributes: {
- [SENTRY_KIND]: 'client',
- [SENTRY_ORIGIN]: ORIGIN,
- [SENTRY_OP]: DB,
- [DB_SYSTEM_NAME]: DB_SYSTEM_VALUE_MYSQL,
- [DB_QUERY_TEXT]: queryText || undefined,
- [DB_QUERY_SUMMARY]: querySummary,
- ...connectionAttributes,
- },
- });
- },
- { requiresParentSpan: true },
- );
+ return startInactiveSpan({ name: getSqlSpanName(attributes, 'mysql2.query'), attributes, onlyIfParent: true });
+ });
}
/**
diff --git a/packages/server-utils/src/integrations/mysql2/mysql2-dc-subscriber.ts b/packages/server-utils/src/integrations/mysql2/mysql2-dc-subscriber.ts
index 72d065956713..e09481e51a66 100644
--- a/packages/server-utils/src/integrations/mysql2/mysql2-dc-subscriber.ts
+++ b/packages/server-utils/src/integrations/mysql2/mysql2-dc-subscriber.ts
@@ -1,9 +1,6 @@
import type { TracingChannel } from 'node:diagnostics_channel';
import {
DB_NAMESPACE,
- DB_OPERATION_NAME,
- DB_QUERY_SUMMARY,
- DB_QUERY_TEXT,
DB_SYSTEM_NAME,
SENTRY_OP,
SERVER_ADDRESS,
@@ -11,9 +8,10 @@ import {
SENTRY_ORIGIN,
} from '@sentry/conventions/attributes';
import { DB } from '@sentry/conventions/op';
-import { getClient, hasSpanStreamingEnabled, startInactiveSpan } from '@sentry/core';
+import type { SpanAttributes } from '@sentry/core';
+import { startInactiveSpan } from '@sentry/core';
import { bindTracingChannelToSpan } from '../../tracing-channel';
-import { sanitizeSqlQueryWithSummary } from '../../utils/sql';
+import { getSqlQueryAttributes, getSqlSpanName } from '../../utils/sql';
// Channel names published by mysql2 >= 3.20.0 (see mysql2 `lib/tracing.js`).
// Hardcoded so the subscriber does not have to import mysql2 — the channels
@@ -26,9 +24,6 @@ export const MYSQL2_DC_CHANNEL_POOL_CONNECT = 'mysql2:pool:connect';
const ORIGIN = 'auto.db.mysql2.native_channel';
const DB_SYSTEM_NAME_VALUE_MYSQL = 'mysql';
-// Leading keyword of a SQL statement (SELECT, INSERT, …) → `db.operation.name`.
-const SQL_OPERATION_RE = /^\s*(\w+)/;
-
/**
* Shape of the context object mysql2 >= 3.20.0 publishes on its query/execute
* tracing channels (see mysql2 `lib/base/connection.js`).
@@ -92,56 +87,37 @@ export function subscribeMysql2DiagnosticChannels(tracingChannel: MySQL2TracingC
}
function setupQueryChannel(tracingChannel: MySQL2TracingChannelFactory, channelName: string): void {
- bindTracingChannelToSpan(
- tracingChannel(channelName),
- data => {
- // mysql2 does not sanitize its channel payload, so the statement may carry
- // raw user values (on the `query` channel they are inlined). Strip every
- // literal before it leaves the process; `values` is never attached.
- const { queryText, querySummary } = sanitizeSqlQueryWithSummary(data.query, 'mysql');
- const operation = queryText?.match(SQL_OPERATION_RE)?.[1]?.toUpperCase();
-
- const client = getClient();
- const name =
- client && hasSpanStreamingEnabled(client)
- ? querySummary || data.database || DB_SYSTEM_NAME_VALUE_MYSQL
- : queryText || 'mysql2.query';
+ bindTracingChannelToSpan(tracingChannel(channelName), data => {
+ // mysql2 does not sanitize its channel payload, so the statement may carry
+ // raw user values (on the `query` channel they are inlined). Strip every
+ // literal before it leaves the process; `values` is never attached.
+ const attributes: SpanAttributes = {
+ [SENTRY_ORIGIN]: ORIGIN,
+ [SENTRY_OP]: DB,
+ [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_MYSQL,
+ ...getSqlQueryAttributes(data.query, 'mysql'),
+ [DB_NAMESPACE]: data.database || undefined,
+ [SERVER_ADDRESS]: data.serverAddress,
+ [SERVER_PORT]: data.serverPort,
+ };
- return startInactiveSpan({
- name,
- attributes: {
- [SENTRY_ORIGIN]: ORIGIN,
- [SENTRY_OP]: DB,
- [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_MYSQL,
- [DB_QUERY_TEXT]: queryText,
- [DB_QUERY_SUMMARY]: querySummary,
- [DB_OPERATION_NAME]: operation,
- [DB_NAMESPACE]: data.database || undefined,
- [SERVER_ADDRESS]: data.serverAddress,
- [SERVER_PORT]: data.serverPort,
- },
- });
- },
- { requiresParentSpan: true },
- );
+ return startInactiveSpan({ name: getSqlSpanName(attributes, 'mysql2.query'), attributes, onlyIfParent: true });
+ });
}
function setupConnectChannel(tracingChannel: MySQL2TracingChannelFactory, channelName: string, spanName: string): void {
- bindTracingChannelToSpan(
- tracingChannel(channelName),
- data => {
- return startInactiveSpan({
- name: spanName,
- attributes: {
- [SENTRY_ORIGIN]: ORIGIN,
- [SENTRY_OP]: DB,
- [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_MYSQL,
- [DB_NAMESPACE]: data.database || undefined,
- [SERVER_ADDRESS]: data.serverAddress,
- [SERVER_PORT]: data.serverPort,
- },
- });
- },
- { requiresParentSpan: true },
- );
+ bindTracingChannelToSpan(tracingChannel(channelName), data => {
+ return startInactiveSpan({
+ name: spanName,
+ onlyIfParent: true,
+ attributes: {
+ [SENTRY_ORIGIN]: ORIGIN,
+ [SENTRY_OP]: DB,
+ [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_MYSQL,
+ [DB_NAMESPACE]: data.database || undefined,
+ [SERVER_ADDRESS]: data.serverAddress,
+ [SERVER_PORT]: data.serverPort,
+ },
+ });
+ });
}
diff --git a/packages/server-utils/src/integrations/neon.ts b/packages/server-utils/src/integrations/neon.ts
index f7e66ff953a2..187d0e532295 100644
--- a/packages/server-utils/src/integrations/neon.ts
+++ b/packages/server-utils/src/integrations/neon.ts
@@ -1,6 +1,7 @@
import * as diagnosticsChannel from '../utils/diagnosticsChannel';
import {
DB_OPERATION_BATCH_SIZE,
+ DB_OPERATION_NAME,
DB_QUERY_SUMMARY,
DB_QUERY_TEXT,
DB_SYSTEM_NAME,
@@ -9,14 +10,14 @@ import {
SENTRY_ORIGIN,
} from '@sentry/conventions/attributes';
import { DB } from '@sentry/conventions/op';
-import type { IntegrationFn, Span } from '@sentry/core';
+import type { IntegrationFn, Span, SpanAttributes } from '@sentry/core';
import { defineIntegration, getActiveSpan, startInactiveSpan } from '@sentry/core';
-import { sanitizeSqlQueryWithSummary } from '../utils/sql';
+import { getLowCardinalitySqlSpanName, getSqlQueryAttributes } from '../utils/sql';
import { CHANNELS } from '../orchestrion/channels';
import { neonModuleNames } from '../orchestrion/config/neon';
import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation';
import { bindTracingChannelToSpan } from '../tracing-channel';
-import { bindPgQueryChannel, getConnectionAttributes } from './postgres';
+import { bindPgQueryChannel, getConnectionAttributes } from './pg';
const INTEGRATION_NAME = 'Neon' as const;
const ORIGIN = 'auto.db.neon';
@@ -60,15 +61,11 @@ function instrumentNeon(): void {
// the pg span shape.
bindPgQueryChannel(CHANNELS.NEON_QUERY, { origin: ORIGIN });
- bindTracingChannelToSpan(
- diagnosticsChannel.tracingChannel(CHANNELS.NEON_HTTP_QUERY),
- data => {
- const span = startInactiveSpan(httpQuerySpanOptions(data.arguments));
- httpQuerySpans.add(span);
- return span;
- },
- { requiresParentSpan: true },
- );
+ bindTracingChannelToSpan(diagnosticsChannel.tracingChannel(CHANNELS.NEON_HTTP_QUERY), data => {
+ const span = startInactiveSpan(httpQuerySpanOptions(data.arguments));
+ httpQuerySpans.add(span);
+ return span;
+ });
// The connection string is resolved inside the HTTP executor, so the query span is active
// when this settles. The executor's own context never sees the string.
@@ -102,23 +99,30 @@ function httpQuerySpanOptions(args: unknown[]): Parameters !!text)
- .map(text => sanitizeSqlQueryWithSummary(text));
- const queryText = joinDefined(statements.map(s => s.queryText));
- const querySummary = joinDefined(statements.map(s => s.querySummary));
- const name = querySummary || DB_SYSTEM_POSTGRESQL;
-
- return {
- name,
- attributes: {
- [SENTRY_OP]: DB,
- [SENTRY_ORIGIN]: ORIGIN,
- [SENTRY_KIND]: 'client',
- [DB_SYSTEM_NAME]: DB_SYSTEM_POSTGRESQL,
- [DB_QUERY_TEXT]: queryText || undefined,
- [DB_QUERY_SUMMARY]: querySummary,
- [DB_OPERATION_BATCH_SIZE]: batchSize,
- },
+ .map(text => getSqlQueryAttributes(text));
+ const queryText = joinDefined(statements.map(s => s[DB_QUERY_TEXT]));
+ const querySummary = joinDefined(statements.map(s => s[DB_QUERY_SUMMARY]));
+ const operationName =
+ batchSize === undefined ? statements[0]?.[DB_OPERATION_NAME] : getBatchOperationName(statements);
+ const attributes: SpanAttributes = {
+ [SENTRY_OP]: DB,
+ [SENTRY_ORIGIN]: ORIGIN,
+ [SENTRY_KIND]: 'client',
+ [DB_SYSTEM_NAME]: DB_SYSTEM_POSTGRESQL,
+ [DB_QUERY_TEXT]: queryText || undefined,
+ [DB_QUERY_SUMMARY]: querySummary,
+ [DB_OPERATION_NAME]: operationName,
+ [DB_OPERATION_BATCH_SIZE]: batchSize,
};
+
+ return { name: getLowCardinalitySqlSpanName(attributes, DB_SYSTEM_POSTGRESQL), onlyIfParent: true, attributes };
+}
+
+// OTel names a batch `BATCH ` when every statement shares one operation, plain `BATCH` otherwise.
+function getBatchOperationName(statements: ReturnType[]): string {
+ const operations = new Set(statements.map(s => s[DB_OPERATION_NAME]?.toUpperCase()));
+ const [operation] = operations;
+ return operations.size === 1 && operation ? `BATCH ${operation}` : 'BATCH';
}
function joinDefined(parts: (string | undefined)[]): string | undefined {
diff --git a/packages/server-utils/src/integrations/nitro/captureStorageEvents.ts b/packages/server-utils/src/integrations/nitro/captureStorageEvents.ts
index d419951912fa..0e464947c37c 100644
--- a/packages/server-utils/src/integrations/nitro/captureStorageEvents.ts
+++ b/packages/server-utils/src/integrations/nitro/captureStorageEvents.ts
@@ -1,4 +1,13 @@
-import { CACHE_HIT, CACHE_KEY, CACHE_OPERATION, SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes';
+import {
+ CACHE_HIT,
+ CACHE_KEY,
+ CACHE_OPERATION,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
+ DB_COLLECTION_NAME,
+ DB_OPERATION_NAME,
+ DB_SYSTEM_NAME,
+} from '@sentry/conventions/attributes';
import { CACHE_GET, CACHE_PUT, CACHE_REMOVE } from '@sentry/conventions/op';
import {
CACHE_OPERATION_NAMES,
@@ -97,9 +106,9 @@ function setupStorageTracingChannel(operation: TracedOperation): void {
[CACHE_OPERATION]: cacheOperationName,
[SENTRY_ORIGIN]: ORIGIN,
[CACHE_KEY]: cacheKeys.length > 1 ? cacheKeys : cacheKeys[0],
- 'db.operation.name': operation,
- 'db.collection.name': mountBase(data),
- 'db.system.name': data.driver?.name ?? 'unknown',
+ [DB_OPERATION_NAME]: operation,
+ [DB_COLLECTION_NAME]: mountBase(data),
+ [DB_SYSTEM_NAME]: data.driver?.name ?? 'unknown',
},
});
},
diff --git a/packages/server-utils/src/integrations/nitro/captureTracingEvents.ts b/packages/server-utils/src/integrations/nitro/captureTracingEvents.ts
index 42f02ed96152..800acc5b30ca 100644
--- a/packages/server-utils/src/integrations/nitro/captureTracingEvents.ts
+++ b/packages/server-utils/src/integrations/nitro/captureTracingEvents.ts
@@ -1,4 +1,4 @@
-import { SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE, SENTRY_ORIGIN } from '@sentry/conventions/attributes';
+import { SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE, SENTRY_ORIGIN, SERVER_PORT } from '@sentry/conventions/attributes';
import { HANDLER, HTTP_SERVER, MIDDLEWARE } from '@sentry/conventions/op';
import {
isObjectLike,
@@ -230,7 +230,7 @@ function setupSrvxTracingChannels(): void {
...headerAttributes,
[SENTRY_ORIGIN]: 'auto.http.nitro.srvx',
[SENTRY_OP]: data.middleware ? MIDDLEWARE : HTTP_SERVER,
- 'server.port': getServerPort(data.server),
+ [SERVER_PORT]: getServerPort(data.server),
},
// Use the same parent span as middleware to make them siblings
parentSpan: requestParentSpans.get(data.request) || undefined,
diff --git a/packages/server-utils/src/integrations/node-cron.ts b/packages/server-utils/src/integrations/node-cron.ts
new file mode 100644
index 000000000000..330870117aca
--- /dev/null
+++ b/packages/server-utils/src/integrations/node-cron.ts
@@ -0,0 +1,104 @@
+import type { IntegrationFn, MonitorConfig } from '@sentry/core';
+import { defineIntegration } from '@sentry/core';
+import { CHANNELS } from '../orchestrion/channels';
+import { nodeCronModuleNames } from '../orchestrion/config/node-cron';
+import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation';
+import type { AnyFn } from '../utils/cronMonitoring';
+import {
+ cronExpressionToMonitorConfig,
+ shouldMonitorJob,
+ subscribeToJobCreation,
+ wrapScheduledJob,
+} from '../utils/cronMonitoring';
+
+const INTEGRATION_NAME = 'NodeCron' as const;
+
+export interface NodeCronIntegrationOptions {
+ /** Task names not to monitor. Strings must match the name exactly. */
+ excludeJobs?: Array;
+}
+
+const _nodeCronIntegration = ((options: NodeCronIntegrationOptions = {}) => {
+ return {
+ name: INTEGRATION_NAME,
+ setup(client) {
+ invokeOrchestrionInstrumentation(client, nodeCronModuleNames, subscribe, [options], {
+ // The binding only parents spans, and this integration creates none.
+ requiresTracingChannelBinding: false,
+ });
+ },
+ };
+}) satisfies IntegrationFn;
+
+/**
+ * Sends Crons check-ins for every named `node-cron` task, with its schedule as the monitor config.
+ * The monitor slug is the task's `name` option. Tasks without a name, or with a schedule Sentry can't express, are not
+ * monitored.
+ */
+export const nodeCronIntegration = defineIntegration(_nodeCronIntegration);
+
+function subscribe(options: NodeCronIntegrationOptions): void {
+ subscribeToJobCreation(CHANNELS.NODE_CRON_CREATE_TASK, INTEGRATION_NAME, args => wrapTask(args, options));
+ subscribeToJobCreation(CHANNELS.NODE_CRON_V4_CREATE_TASK, INTEGRATION_NAME, args =>
+ wrapTask(args, options, isReadAlikeByNodeCron4),
+ );
+ subscribeToJobCreation(CHANNELS.NODE_CRON_V3_CREATE_TASK, INTEGRATION_NAME, args =>
+ wrapTask(args, options, isReadAlikeByNodeCron3),
+ );
+}
+
+// `createTask(expression, func, options)`
+function wrapTask(
+ args: unknown[],
+ options: NodeCronIntegrationOptions,
+ isReadAlike?: (expression: string) => boolean,
+): void {
+ const [expression, func, taskOptions] = args;
+ const { name, timezone } = (taskOptions || {}) as { name?: unknown; timezone?: unknown };
+ if (!shouldMonitorJob(name, func, options.excludeJobs)) {
+ return;
+ }
+ const monitorConfig = getMonitorConfig(String(expression), timezone, isReadAlike);
+ args[1] = wrapScheduledJob(func as AnyFn, name, monitorConfig, 'auto.function.node-cron');
+}
+
+/** The monitor config for a task, or `undefined` if `isReadAlike` says this node-cron version reads it differently. */
+export function getMonitorConfig(
+ expression: string,
+ timezone: unknown,
+ isReadAlike?: (expression: string) => boolean,
+): MonitorConfig | undefined {
+ return isReadAlike && !isReadAlike(expression)
+ ? undefined
+ : cronExpressionToMonitorConfig(expression, timezone, 'and');
+}
+
+const NODE_CRON_3_FIELD_MINIMUMS = [0, 0, 1, 1, 0];
+
+/**
+ * node-cron before 4.6 reads `7` as `0` and swaps reversed ranges, so it runs `sat-sun`, `6-0` and `6-7` every day.
+ * Returns `false` for weekday ranges that could hit that.
+ */
+export function isReadAlikeByNodeCron4(expression: string): boolean {
+ const weekday = expression.trim().split(/\s+/).pop() || '';
+ return !(weekday.includes('-') && (weekday.includes('7') || /-(?:0|sun)/i.test(weekday)));
+}
+
+/**
+ * node-cron 3 keeps the values of a stepped field that are multiples of the step (`*\/2` days are 2, 4, ...)
+ * and reads weekday ranges like node-cron 4 before 4.6. Returns `false` where that differs from standard cron.
+ */
+export function isReadAlikeByNodeCron3(expression: string): boolean {
+ const fields = expression.trim().split(/\s+/);
+ const minimums = fields.length === 6 ? [0, ...NODE_CRON_3_FIELD_MINIMUMS] : NODE_CRON_3_FIELD_MINIMUMS;
+ return (
+ isReadAlikeByNodeCron4(expression) &&
+ fields.every((field, index) => {
+ if (!field.includes('/')) {
+ return true;
+ }
+ const match = /^(?:\*|(\d+)-\d+)\/(\d+)$/.exec(field);
+ return !!match && Number(match[1] ?? minimums[index]) % Number(match[2]) === 0;
+ })
+ );
+}
diff --git a/packages/server-utils/src/integrations/node-schedule.ts b/packages/server-utils/src/integrations/node-schedule.ts
new file mode 100644
index 000000000000..426c362d0021
--- /dev/null
+++ b/packages/server-utils/src/integrations/node-schedule.ts
@@ -0,0 +1,112 @@
+import type { IntegrationFn, MonitorConfig } from '@sentry/core';
+import { defineIntegration } from '@sentry/core';
+import { CHANNELS } from '../orchestrion/channels';
+import { nodeScheduleModuleNames } from '../orchestrion/config/node-schedule';
+import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation';
+import type { AnyFn, ScheduleChange } from '../utils/cronMonitoring';
+import {
+ cronExpressionToMonitorConfig,
+ shouldMonitorJob,
+ subscribeToScheduleChange,
+ updateMonitorConfig,
+ wrapScheduledJob,
+} from '../utils/cronMonitoring';
+
+const INTEGRATION_NAME = 'NodeSchedule' as const;
+
+export interface NodeScheduleIntegrationOptions {
+ /** Job names not to monitor. Strings must match the name exactly. */
+ excludeJobs?: Array;
+}
+
+const _nodeScheduleIntegration = ((options: NodeScheduleIntegrationOptions = {}) => {
+ return {
+ name: INTEGRATION_NAME,
+ setup(client) {
+ invokeOrchestrionInstrumentation(client, nodeScheduleModuleNames, subscribe, [options], {
+ requiresTracingChannelBinding: false,
+ });
+ },
+ };
+}) satisfies IntegrationFn;
+
+/**
+ * Sends Crons check-ins for every named `node-schedule` job, whether it's created with `scheduleJob` or `new Job()`.
+ * The monitor slug is the job name. Jobs without a name, one-off jobs, and jobs with a schedule Crons can't express
+ * are not monitored.
+ */
+export const nodeScheduleIntegration = defineIntegration(_nodeScheduleIntegration);
+
+function subscribe(options: NodeScheduleIntegrationOptions): void {
+ subscribeToScheduleChange(CHANNELS.NODE_SCHEDULE_SCHEDULE, INTEGRATION_NAME, onSchedule, change =>
+ beforeSchedule(change, options),
+ );
+}
+
+interface Job {
+ name?: unknown;
+ job?: unknown;
+ nextInvocation?: () => unknown;
+}
+
+// The name node-schedule gives jobs created without one.
+const ANONYMOUS_NAME = /^]/;
+
+// `job.schedule(spec)` calls that add to runs the job already has pending, rather than replace them.
+const addsToPendingRuns = new WeakSet();
+
+/** Wraps a named job's method before `job.schedule(spec)` with a recurring spec, unless it's already wrapped. */
+export function beforeSchedule(change: ScheduleChange, options: NodeScheduleIntegrationOptions): void {
+ const job = change.self as Job | undefined;
+ if (!job) {
+ return;
+ }
+ if (typeof job.nextInvocation === 'function' && job.nextInvocation() != null) {
+ addsToPendingRuns.add(change);
+ }
+ const name = typeof job.name === 'string' && !ANONYMOUS_NAME.test(job.name) ? job.name : undefined;
+ if (isOneOffSpec(change.arguments[0]) || !shouldMonitorJob(name, job.job, options.excludeJobs)) {
+ return;
+ }
+ job.job = wrapScheduledJob(job.job as AnyFn, name, undefined, 'auto.function.node-schedule');
+}
+
+/**
+ * Sets the spec's config after `job.schedule(spec)`. Without one, from a spec Crons can't express or a job that also
+ * keeps runs from an earlier spec, the job doesn't check in.
+ */
+export function onSchedule(change: ScheduleChange): void {
+ // `schedule` returns false when it scheduled nothing, and `reschedule` then keeps the old schedule.
+ if (change.result === true) {
+ const job = change.self as Job | undefined;
+ updateMonitorConfig(job?.job, addsToPendingRuns.has(change) ? undefined : getMonitorConfig(change.arguments[0]));
+ }
+}
+
+/** Whether node-schedule runs a job with this spec once: a `Date`, a timestamp, or a date string. */
+export function isOneOffSpec(spec: unknown): boolean {
+ const { rule } = (spec || {}) as { rule?: unknown };
+ const when = typeof spec === 'object' && rule ? rule : spec;
+ if (typeof when === 'string') {
+ // `Date.parse` also accepts some crons, like `0 9 * * *`.
+ return !isCronShaped(when) && !Number.isNaN(Date.parse(when));
+ }
+ return when instanceof Date || typeof when === 'number';
+}
+
+function isCronShaped(value: string): boolean {
+ const fields = value.trim().split(/\s+/);
+ return (
+ /^@[a-z]+$/i.test(value.trim()) ||
+ ((fields.length === 5 || fields.length === 6) && fields.every(field => /^[\d*,/?#a-z-]+$/i.test(field)))
+ );
+}
+
+// Only cron strings convert; recurrence rules and rules limited by `start` or `end` don't.
+export function getMonitorConfig(spec: unknown): MonitorConfig | undefined {
+ if (typeof spec === 'string') {
+ return cronExpressionToMonitorConfig(spec, undefined, 'or');
+ }
+ const { rule, tz, start, end } = (spec || {}) as { rule?: unknown; tz?: unknown; start?: unknown; end?: unknown };
+ return typeof rule === 'string' && !start && !end ? cronExpressionToMonitorConfig(rule, tz, 'or') : undefined;
+}
diff --git a/packages/server-utils/src/integrations/postgres.ts b/packages/server-utils/src/integrations/pg.ts
similarity index 88%
rename from packages/server-utils/src/integrations/postgres.ts
rename to packages/server-utils/src/integrations/pg.ts
index 9dc4e62bf01e..24f648d92acb 100644
--- a/packages/server-utils/src/integrations/postgres.ts
+++ b/packages/server-utils/src/integrations/pg.ts
@@ -1,8 +1,6 @@
import * as diagnosticsChannel from '../utils/diagnosticsChannel';
import {
DB_NAMESPACE,
- DB_QUERY_SUMMARY,
- DB_QUERY_TEXT,
DB_SYSTEM_NAME,
DB_USER,
SENTRY_KIND,
@@ -10,19 +8,12 @@ import {
SERVER_ADDRESS,
SERVER_PORT,
SENTRY_ORIGIN,
+ DB_CONNECTION_STRING,
} from '@sentry/conventions/attributes';
import { DB } from '@sentry/conventions/op';
import type { IntegrationFn, Scope, SpanAttributes } from '@sentry/core';
-import {
- isObjectLike,
- bindScopeToEmitter,
- defineIntegration,
- getClient,
- getCurrentScope,
- hasSpanStreamingEnabled,
- startInactiveSpan,
-} from '@sentry/core';
-import { sanitizeSqlQueryWithSummary } from '../utils/sql';
+import { isObjectLike, bindScopeToEmitter, defineIntegration, getCurrentScope, startInactiveSpan } from '@sentry/core';
+import { getSqlQueryAttributes, getSqlSpanName } from '../utils/sql';
import type { OrchestrionChannelContext } from '../orchestrion/types';
import { CHANNELS } from '../orchestrion/channels';
import { bindTracingChannelToSpan } from '../tracing-channel';
@@ -37,9 +28,6 @@ const INTEGRATION_NAME = 'Postgres' as const;
// so they default to 'manual').
const ORIGIN = 'auto.db.postgres';
-// `db.connection_string` is not part of `@sentry/conventions`, so it stays
-// inlined, keeping this integration free of `@opentelemetry/*` deps.
-const ATTR_DB_CONNECTION_STRING = 'db.connection_string';
const ATTR_PG_PLAN = 'db.postgresql.plan';
const ATTR_PG_IDLE_TIMEOUT = 'db.postgresql.idle.timeout.millis';
const ATTR_PG_MAX_CLIENT = 'db.postgresql.max.client';
@@ -143,6 +131,7 @@ function subscribeQueryLikeChannel(
const spanOptions = getSpanOptions(data);
return startInactiveSpan({
...spanOptions,
+ onlyIfParent: true,
attributes: { ...spanOptions.attributes, [SENTRY_KIND]: 'client' },
});
},
@@ -153,9 +142,6 @@ function subscribeQueryLikeChannel(
// `query` can return a streamable `Submittable`, so only it defers.
deferStreamedResult
? {
- // Only instrument under an active span, leaving the context untouched otherwise
- // (e.g. connects issued during app startup).
- requiresParentSpan: true,
// Streamable `Submittable` (e.g. `client.query(new Query())`)
// returns an emitter that orchestrion stores on `ctx.result` while
// firing no async events; the query isn't done until the emitter
@@ -182,7 +168,7 @@ function subscribeQueryLikeChannel(
return true;
},
}
- : { requiresParentSpan: true },
+ : undefined,
);
}
@@ -192,24 +178,15 @@ function querySpanOptions(
): { name: string; attributes: SpanAttributes } {
const params = (ctx.self as { connectionParameters?: PgConnectionParams } | undefined)?.connectionParameters ?? {};
const queryConfig = extractQueryConfig(ctx.arguments);
- const client = getClient();
- const { queryText, querySummary } = sanitizeSqlQueryWithSummary(queryConfig?.text);
- const name =
- client && hasSpanStreamingEnabled(client)
- ? querySummary || params.database || DB_SYSTEM_POSTGRESQL
- : (queryText ?? SPAN_QUERY_FALLBACK);
-
- return {
- name,
- attributes: {
- [SENTRY_OP]: DB,
- ...(connectionString ? getPgConnectionAttributes(params) : getConnectionAttributes(params)),
- [SENTRY_ORIGIN]: origin,
- [DB_QUERY_TEXT]: queryText || undefined,
- [DB_QUERY_SUMMARY]: querySummary,
- [ATTR_PG_PLAN]: typeof queryConfig?.name === 'string' ? queryConfig.name : undefined,
- },
+ const attributes: SpanAttributes = {
+ [SENTRY_OP]: DB,
+ ...(connectionString ? getPgConnectionAttributes(params) : getConnectionAttributes(params)),
+ [SENTRY_ORIGIN]: origin,
+ ...getSqlQueryAttributes(queryConfig?.text),
+ [ATTR_PG_PLAN]: typeof queryConfig?.name === 'string' ? queryConfig.name : undefined,
};
+
+ return { name: getSqlSpanName(attributes, SPAN_QUERY_FALLBACK), attributes };
}
function connectSpanOptions(ctx: PgChannelContext): { name: string; attributes: SpanAttributes } {
@@ -254,7 +231,8 @@ export function getConnectionAttributes(params: PgConnectionParams): SpanAttribu
}
function getPgConnectionAttributes(params: PgConnectionParams): SpanAttributes {
- return { ...getConnectionAttributes(params), [ATTR_DB_CONNECTION_STRING]: getConnectionString(params) };
+ // oxlint-disable-next-line typescript/no-deprecated -- Preserve the existing attribute name.
+ return { ...getConnectionAttributes(params), [DB_CONNECTION_STRING]: getConnectionString(params) };
}
function getPoolConnectionAttributes(opts: PgPoolOptions): SpanAttributes {
@@ -273,7 +251,8 @@ function getPoolConnectionAttributes(opts: PgPoolOptions): SpanAttributes {
const user = url?.username || opts.user;
return {
[DB_SYSTEM_NAME]: DB_SYSTEM_POSTGRESQL,
- [ATTR_DB_CONNECTION_STRING]: getConnectionString(opts),
+ // oxlint-disable-next-line typescript/no-deprecated -- Preserve the existing attribute name.
+ [DB_CONNECTION_STRING]: getConnectionString(opts),
[ATTR_PG_IDLE_TIMEOUT]: opts.idleTimeoutMillis,
[ATTR_PG_MAX_CLIENT]: opts.max,
[DB_NAMESPACE]: database,
diff --git a/packages/server-utils/src/integrations/postgresjs.ts b/packages/server-utils/src/integrations/postgresjs-wrapper.ts
similarity index 73%
rename from packages/server-utils/src/integrations/postgresjs.ts
rename to packages/server-utils/src/integrations/postgresjs-wrapper.ts
index 8588ed1a56ea..d387c299ffc9 100644
--- a/packages/server-utils/src/integrations/postgresjs.ts
+++ b/packages/server-utils/src/integrations/postgresjs-wrapper.ts
@@ -4,10 +4,9 @@
/* eslint-disable max-lines */
import {
+ addNonEnumerableProperty,
debug,
getActiveSpan,
- getClient,
- hasSpanStreamingEnabled,
isObjectLike,
type Span,
type SpanAttributes,
@@ -15,21 +14,26 @@ import {
startSpanManual,
} from '@sentry/core';
import { DEBUG_BUILD } from '../debug-build';
-import { getSqlQuerySummary, sanitizeSqlQuery } from '../utils/sql';
+import { getSqlQueryAttributes, getSqlSpanName } from '../utils/sql';
import {
DB_NAMESPACE,
DB_OPERATION_NAME,
- DB_QUERY_SUMMARY,
DB_QUERY_TEXT,
DB_SYSTEM_NAME,
SENTRY_OP,
SENTRY_ORIGIN,
SERVER_ADDRESS,
SERVER_PORT,
+ DB_RESPONSE_STATUS_CODE,
+ ERROR_TYPE,
} from '@sentry/conventions/attributes';
import { DB } from '@sentry/conventions/op';
-const SQL_OPERATION_REGEX = /^(SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER)/i;
+/** The callbacks postgres.js calls on a `Query` when it settles. */
+export interface PostgresQuerySettlement {
+ resolve?: (...args: unknown[]) => unknown;
+ reject?: (...args: unknown[]) => unknown;
+}
export type PostgresConnectionContext = {
ATTR_DB_NAMESPACE?: string;
@@ -37,7 +41,7 @@ export type PostgresConnectionContext = {
ATTR_SERVER_PORT?: string;
};
-interface PostgresJsSqlInstrumentationOptions {
+export interface PostgresJsSqlInstrumentationOptions {
/**
* Whether to require a parent span for the instrumentation.
* If set to true, the instrumentation will only create spans if there is a parent span
@@ -61,6 +65,9 @@ const INSTRUMENTED_MARKER = Symbol.for('sentry.instrumented.postgresjs');
// This prevents double-spanning when both the wrapper and the Node.js Query.prototype
// fallback patch are active simultaneously.
const QUERY_FROM_INSTRUMENTED_SQL = Symbol.for('sentry.query.from.instrumented.sql');
+// postgres >= 4 publishes `postgres:query` without exposing the `Query`, so the native channel
+// subscriber can't read QUERY_FROM_INSTRUMENTED_SQL. It checks this marker on the active span instead.
+const POSTGRESJS_WRAPPER_SPAN = Symbol.for('sentry.postgresjs.wrapper.span');
/**
* Instruments a postgres.js `sql` instance with Sentry tracing.
@@ -237,108 +244,72 @@ function _wrapSingleQueryHandle(
// IMPORTANT: We must replace the handle function directly, not use a Proxy,
// because Query.then() internally calls this.handle(), which would bypass a Proxy wrapper.
- const wrappedHandle = async function (this: { executed?: boolean }, ...args: unknown[]): Promise {
+ const wrappedHandle = async function (
+ this: PostgresQuerySettlement & { executed?: boolean },
+ ...args: unknown[]
+ ): Promise {
// postgres.js calls handle() from then/catch/finally — only the first call executes SQL,
// subsequent calls are no-ops (guarded by this.executed). Skip span creation for no-ops.
if (this.executed || !_shouldCreateSpans(options)) {
return originalHandle.apply(this, args);
}
- const fullQuery = _reconstructQuery(query.strings);
- const sanitizedSqlQuery = sanitizeSqlQuery(fullQuery);
-
- const client = getClient();
- const querySummary = getSqlQuerySummary(sanitizedSqlQuery);
+ const queryAttributes = getSqlQueryAttributes(_reconstructQuery(query.strings));
+ const sanitizedSqlQuery = queryAttributes[DB_QUERY_TEXT] ?? '';
const connectionContext = sqlInstance
? ((sqlInstance as Record)[CONNECTION_CONTEXT_SYMBOL] as PostgresConnectionContext | undefined)
: undefined;
- const name =
- client && hasSpanStreamingEnabled(client)
- ? querySummary || connectionContext?.ATTR_DB_NAMESPACE || 'postgres'
- : sanitizedSqlQuery || 'postgresjs.query';
-
- return startSpanManual(
- {
- name,
- attributes: {
- [SENTRY_OP]: DB,
- [SENTRY_ORIGIN]: 'auto.db.postgresjs',
- [DB_SYSTEM_NAME]: 'postgres',
- [DB_QUERY_TEXT]: sanitizedSqlQuery,
- [DB_QUERY_SUMMARY]: querySummary,
- [DB_OPERATION_NAME]: _getOperationName(sanitizedSqlQuery),
- ...(connectionContext && _getConnectionAttributes(connectionContext)),
- },
- },
- (span: Span) => {
- if (options.requestHook) {
- try {
- options.requestHook(span, sanitizedSqlQuery, connectionContext);
- } catch (e) {
- span.setAttribute('sentry.hook.error', 'requestHook failed');
- DEBUG_BUILD && debug.error('Error in requestHook for PostgresJs instrumentation:', e);
- }
- }
-
- const queryWithCallbacks = this as {
- resolve: unknown;
- reject: unknown;
- };
-
- queryWithCallbacks.resolve = new Proxy(queryWithCallbacks.resolve as (...args: unknown[]) => unknown, {
- apply: (resolveTarget, resolveThisArg, resolveArgs: [{ command?: string }]) => {
- try {
- // Re-set the operation name with the server-reported command, which is more reliable than the query text.
- span.setAttribute(DB_OPERATION_NAME, _getOperationName(sanitizedSqlQuery, resolveArgs?.[0]?.command));
- span.end();
- } catch (e) {
- DEBUG_BUILD && debug.error('Error ending span in resolve callback:', e);
- }
-
- return Reflect.apply(resolveTarget, resolveThisArg, resolveArgs);
- },
- });
+ const attributes: SpanAttributes = {
+ [SENTRY_OP]: DB,
+ [SENTRY_ORIGIN]: 'auto.db.postgresjs',
+ [DB_SYSTEM_NAME]: 'postgres',
+ ...queryAttributes,
+ ...(connectionContext && _getConnectionAttributes(connectionContext)),
+ };
- queryWithCallbacks.reject = new Proxy(queryWithCallbacks.reject as (...args: unknown[]) => unknown, {
- apply: (rejectTarget, rejectThisArg, rejectArgs: { message?: string; code?: string; name?: string }[]) => {
- try {
- span.setStatus({
- code: SPAN_STATUS_ERROR,
- message: rejectArgs?.[0]?.message || 'unknown_error',
- });
-
- span.setAttribute('db.response.status_code', rejectArgs?.[0]?.code || 'unknown');
- span.setAttribute('error.type', rejectArgs?.[0]?.name || 'unknown');
-
- span.end();
- } catch (e) {
- DEBUG_BUILD && debug.error('Error ending span in reject callback:', e);
- }
- return Reflect.apply(rejectTarget, rejectThisArg, rejectArgs);
- },
- });
+ return startSpanManual({ name: getSqlSpanName(attributes, 'postgresjs.query'), attributes }, (span: Span) => {
+ addNonEnumerableProperty(span, POSTGRESJS_WRAPPER_SPAN, true);
- // Handle synchronous errors that might occur before promise is created
+ if (options.requestHook) {
try {
- return originalHandle.apply(this, args);
+ options.requestHook(span, sanitizedSqlQuery, connectionContext);
} catch (e) {
- span.setStatus({
- code: SPAN_STATUS_ERROR,
- message: e instanceof Error ? e.message : 'unknown_error',
- });
- span.end();
- throw e;
+ span.setAttribute('sentry.hook.error', 'requestHook failed');
+ DEBUG_BUILD && debug.error('Error in requestHook for PostgresJs instrumentation:', e);
}
- },
- );
+ }
+
+ _wrapQuerySettlement(this, span, queryAttributes[DB_OPERATION_NAME]);
+
+ // Handle synchronous errors that might occur before promise is created
+ try {
+ return originalHandle.apply(this, args);
+ } catch (e) {
+ span.setStatus({
+ code: SPAN_STATUS_ERROR,
+ message: e instanceof Error ? e.message : 'unknown_error',
+ });
+ span.end();
+ throw e;
+ }
+ });
};
(wrappedHandle as { __sentryWrapped?: boolean }).__sentryWrapped = true;
query.handle = wrappedHandle;
}
+/**
+ * Whether `span` was started by {@link instrumentPostgresJsSql}.
+ *
+ * @internal Exported for the diagnostics-channel integration.
+ */
+export function _isPostgresJsWrapperSpan(span: Span | undefined): boolean {
+ return !!span && POSTGRESJS_WRAPPER_SPAN in span;
+}
+
/**
* Determines whether a span should be created based on the current context.
* If `requireParentSpan` is set to true in the options, a span will
@@ -394,20 +365,6 @@ export function _getConnectionAttributes(connectionContext: PostgresConnectionCo
return attributes;
}
-/**
- * Extracts the DB operation name from a SQL query, preferring the server-reported `command`.
- *
- * @internal Exported for the diagnostics-channel integration.
- */
-export function _getOperationName(sanitizedQuery: string | undefined, command?: string): string | undefined {
- if (command) {
- return command;
- }
- // Fallback: extract operation from the SQL query
- const operationMatch = sanitizedQuery?.match(SQL_OPERATION_REGEX);
- return operationMatch?.[1]?.toUpperCase();
-}
-
/**
* Builds a {@link PostgresConnectionContext} from postgres.js' parsed options
* (which store `host`/`port` as arrays). Defaults to 'localhost'/5432.
@@ -428,6 +385,55 @@ export function _buildConnectionContext(options: {
};
}
+/**
+ * Wrap `query.resolve`/`query.reject` so the span ends when the query settles.
+ *
+ * `Query extends Promise` and `async handle()` only dispatches — its promise
+ * resolves immediately, long before the query completes. postgres.js signals
+ * completion by calling `this.resolve`/`this.reject`, so we own the span end
+ * there. Wrapping happens at handle-start because `reject` can fire
+ * synchronously during dispatch and `cursor()` reassigns both before executing.
+ */
+export function _wrapQuerySettlement(
+ query: PostgresQuerySettlement,
+ span: Span,
+ operationName: string | undefined,
+ onSettle?: () => void,
+): void {
+ const originalResolve = query.resolve;
+ if (typeof originalResolve === 'function') {
+ query.resolve = function (this: unknown, ...resolveArgs: unknown[]): unknown {
+ onSettle?.();
+ try {
+ const command = (resolveArgs[0] as { command?: string } | undefined)?.command;
+ // Re-set the operation name with the server-reported command, which is more reliable than the query text.
+ span.setAttribute(DB_OPERATION_NAME, command || operationName);
+ span.end();
+ } catch (e) {
+ DEBUG_BUILD && debug.error('[instrumentation:postgresjs] error ending span in resolve:', e);
+ }
+ return originalResolve.apply(this, resolveArgs);
+ };
+ }
+
+ const originalReject = query.reject;
+ if (typeof originalReject === 'function') {
+ query.reject = function (this: unknown, ...rejectArgs: unknown[]): unknown {
+ onSettle?.();
+ try {
+ const err = rejectArgs[0] as { message?: string; code?: string; name?: string } | undefined;
+ span.setStatus({ code: SPAN_STATUS_ERROR, message: err?.message || 'unknown_error' });
+ span.setAttribute(DB_RESPONSE_STATUS_CODE, err?.code || 'unknown');
+ span.setAttribute(ERROR_TYPE, err?.name || 'unknown');
+ span.end();
+ } catch (e) {
+ DEBUG_BUILD && debug.error('[instrumentation:postgresjs] error ending span in reject:', e);
+ }
+ return originalReject.apply(this, rejectArgs);
+ };
+ }
+}
+
/**
* Extracts and stores connection context from sql.options.
*/
diff --git a/packages/server-utils/src/integrations/postgres-js.ts b/packages/server-utils/src/integrations/postgresjs/index.ts
similarity index 57%
rename from packages/server-utils/src/integrations/postgres-js.ts
rename to packages/server-utils/src/integrations/postgresjs/index.ts
index 0ac51daa8f54..86c46f6b26ac 100644
--- a/packages/server-utils/src/integrations/postgres-js.ts
+++ b/packages/server-utils/src/integrations/postgresjs/index.ts
@@ -1,38 +1,31 @@
-import * as diagnosticsChannel from '../utils/diagnosticsChannel';
+import * as diagnosticsChannel from '../../utils/diagnosticsChannel';
import {
DB_OPERATION_NAME,
- DB_QUERY_SUMMARY,
DB_QUERY_TEXT,
DB_SYSTEM_NAME,
- ERROR_TYPE,
SENTRY_KIND,
SENTRY_OP,
SENTRY_ORIGIN,
} from '@sentry/conventions/attributes';
import { DB } from '@sentry/conventions/op';
-import type { IntegrationFn, Span } from '@sentry/core';
-import {
- debug,
- defineIntegration,
- getClient,
- hasSpanStreamingEnabled,
- SPAN_STATUS_ERROR,
- startInactiveSpan,
-} from '@sentry/core';
-import { DEBUG_BUILD } from '../debug-build';
-import { getSqlQuerySummary, sanitizeSqlQuery } from '../utils/sql';
+import type { IntegrationFn, Span, SpanAttributes } from '@sentry/core';
+import { debug, defineIntegration, startInactiveSpan, waitForTracingChannelBinding } from '@sentry/core';
+import { DEBUG_BUILD } from '../../debug-build';
+import { getSqlQueryAttributes, getSqlSpanName } from '../../utils/sql';
import {
_buildConnectionContext,
_getConnectionAttributes,
- _getOperationName,
_reconstructQuery,
+ _wrapQuerySettlement,
type PostgresConnectionContext,
-} from './postgresjs';
-import type { OrchestrionChannelContext } from '../orchestrion/types';
-import { CHANNELS } from '../orchestrion/channels';
-import { bindTracingChannelToSpan } from '../tracing-channel';
-import { postgresJsModuleNames } from '../orchestrion/config/postgres';
-import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation';
+ type PostgresQuerySettlement,
+} from '../postgresjs-wrapper';
+import type { OrchestrionChannelContext } from '../../orchestrion/types';
+import { CHANNELS } from '../../orchestrion/channels';
+import { bindTracingChannelToSpan } from '../../tracing-channel';
+import { postgresJsModuleNames } from '../../orchestrion/config/postgres';
+import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation';
+import { subscribePostgresJsDiagnosticChannels } from './postgresjs-dc-subscriber';
// Same name as the OTel `PostgresJs` integration by design, so the OTel
// integration of the same name is deduplicated out of the default set.
@@ -42,10 +35,6 @@ const ORIGIN = 'auto.db.postgresjs';
const DB_SYSTEM_NAME_POSTGRES = 'postgres';
-// Not part of `@sentry/conventions`, so we keep it inline (matches older OTel
-// `PostgresJsInstrumentation`).
-const DB_RESPONSE_STATUS_CODE = 'db.response.status_code';
-
const NOOP = (): void => {};
// Same `Symbol.for()` marker the core `instrumentPostgresJsSql` wrapper sets on
@@ -54,9 +43,6 @@ const QUERY_FROM_INSTRUMENTED_SQL = Symbol.for('sentry.query.from.instrumented.s
// The query span, stashed on the `Query` so the `execute`/`connect` channels can
// attach connection attributes to it.
const QUERY_SPAN = Symbol('sentryPostgresJsSpan');
-// Set once connection attributes are on the span, so the fallback and the
-// `execute`/`connect` channels don't both write them.
-const CONNECTION_ATTRS_SET = Symbol('sentryPostgresJsConnectionAttrsSet');
// Set on the channel context once the resolve/reject wrappers have ended the
// span, so `deferSpanEnd` knows the wrappers own the lifecycle.
const SPAN_ENDED = Symbol('sentryPostgresJsSpanEnded');
@@ -75,11 +61,9 @@ export interface PostgresJsIntegrationOptions {
}
/** The `Query` instance postgres.js passes as `self` to `Query.prototype.handle`. */
-interface PostgresQuery {
+interface PostgresQuery extends PostgresQuerySettlement {
strings?: string[];
executed?: boolean;
- resolve?: (...args: unknown[]) => unknown;
- reject?: (...args: unknown[]) => unknown;
}
interface PostgresJsQueryContext extends OrchestrionChannelContext {
@@ -128,23 +112,11 @@ function recordConnectionFromChannel(message: PostgresJsQueryContext): void {
registerEndpoint(context);
}
-function setConnectionAttributes(span: Span, query: PostgresQuery, context: PostgresConnectionContext): void {
- const queryRecord = query as Record;
- if (queryRecord[CONNECTION_ATTRS_SET]) {
- return;
- }
- queryRecord[CONNECTION_ATTRS_SET] = true;
- if (context) {
- span.setAttributes(_getConnectionAttributes(context));
- }
-}
-
/**
* Backfill connection attributes onto a query's span from a channel whose `self`
* is the connection object and `arguments[0]` the query. Shared by the `execute`
* and `connect` channels; both carry that shape and both resolve the context via
- * the `connectionContexts` WeakMap. Idempotent (guarded inside
- * `setConnectionAttributes`).
+ * the `connectionContexts` WeakMap.
*/
function attachConnectionAttributesFromChannel(message: PostgresJsQueryContext): void {
const connection = message.self;
@@ -155,69 +127,25 @@ function attachConnectionAttributesFromChannel(message: PostgresJsQueryContext):
const span = (query as Record)[QUERY_SPAN] as Span | undefined;
const context = connectionContexts.get(connection);
if (span && context) {
- setConnectionAttributes(span, query, context);
- }
-}
-
-/**
- * Wrap `query.resolve`/`query.reject` so the span ends when the query settles.
- *
- * `Query extends Promise` and `async handle()` only dispatches — its promise
- * resolves immediately, long before the query completes. postgres.js signals
- * completion by calling `this.resolve`/`this.reject`, so we own the span end
- * there. Wrapping happens at handle-start because `reject` can fire
- * synchronously during dispatch and `cursor()` reassigns both before executing.
- */
-function wrapQuerySettlement(data: PostgresJsQueryContext, span: Span, sanitizedSqlQuery: string): void {
- const query = data.self;
- if (!query) {
- return;
- }
-
- // Claim ownership of ending the span up front, so `deferSpanEnd` defers to the
- // wrapper even if `span.end()` below throws.
- const markEnded = (): void => {
- data[SPAN_ENDED] = true;
- };
-
- const originalResolve = query.resolve;
- if (typeof originalResolve === 'function') {
- query.resolve = function (this: unknown, ...resolveArgs: unknown[]): unknown {
- markEnded();
- try {
- const command = (resolveArgs[0] as { command?: string } | undefined)?.command;
- // Re-set the operation name with the server-reported command, which is more reliable than the query text.
- span.setAttribute(DB_OPERATION_NAME, _getOperationName(sanitizedSqlQuery, command));
- span.end();
- } catch (e) {
- DEBUG_BUILD && debug.error('[instrumentation:postgresjs] error ending span in resolve:', e);
- }
- return originalResolve.apply(this, resolveArgs);
- };
- }
-
- const originalReject = query.reject;
- if (typeof originalReject === 'function') {
- query.reject = function (this: unknown, ...rejectArgs: unknown[]): unknown {
- markEnded();
- try {
- const err = rejectArgs[0] as { message?: string; code?: string; name?: string } | undefined;
- span.setStatus({ code: SPAN_STATUS_ERROR, message: err?.message || 'unknown_error' });
- span.setAttribute(DB_RESPONSE_STATUS_CODE, err?.code || 'unknown');
- span.setAttribute(ERROR_TYPE, err?.name || 'unknown');
- span.end();
- } catch (e) {
- DEBUG_BUILD && debug.error('[instrumentation:postgresjs] error ending span in reject:', e);
- }
- return originalReject.apply(this, rejectArgs);
- };
+ span.setAttributes(_getConnectionAttributes(context));
}
}
const _postgresJsIntegration = ((options: PostgresJsIntegrationOptions = {}) => {
return {
name: INTEGRATION_NAME,
+ setupOnce() {
+ // postgres >= 4 publishes its own `postgres:query` tracing channel, with no orchestrion event to
+ // signal that it was loaded, so subscribe eagerly.
+ if (!diagnosticsChannel.tracingChannel) {
+ return;
+ }
+ waitForTracingChannelBinding(() => {
+ subscribePostgresJsDiagnosticChannels(diagnosticsChannel.tracingChannel, options);
+ });
+ },
setup(client) {
+ // postgres 3.x is covered by the orchestrion-injected channels, subscribed lazily.
invokeOrchestrionInstrumentation(client, postgresJsModuleNames, instrumentPostgresJs, [options]);
},
};
@@ -270,89 +198,76 @@ function instrumentPostgresJs(options: PostgresJsIntegrationOptions): void {
// Opt out of re-entrant `handle()` calls (then/catch/finally re-invoke
// it, guarded by `executed`) and queries already wrapped by the portable
- // `instrumentPostgresJsSql`. The parent-span requirement is applied via
- // `requiresParentSpan` below.
+ // `instrumentPostgresJsSql`.
if (query.executed === true || (query as Record)[QUERY_FROM_INSTRUMENTED_SQL]) {
return undefined;
}
- const fullQuery = _reconstructQuery(query.strings);
- const sanitizedSqlQuery = sanitizeSqlQuery(fullQuery);
-
- const querySummary = getSqlQuerySummary(sanitizedSqlQuery);
-
- const client = getClient();
+ const queryAttributes = getSqlQueryAttributes(_reconstructQuery(query.strings));
// Single-endpoint fallback: resolve context now so the span name and `requestHook` have
// it, and the first-query-per-connection (bare `execute`) path still gets attrs.
const context = resolveSingleEndpoint();
- const name =
- client && hasSpanStreamingEnabled(client)
- ? querySummary || context?.ATTR_DB_NAMESPACE || DB_SYSTEM_NAME_POSTGRES
- : sanitizedSqlQuery || 'postgresjs.query';
-
// `sentry.kind: client` matches the mysql/pg channel subscribers.
+ const attributes: SpanAttributes = {
+ [SENTRY_OP]: DB,
+ [SENTRY_KIND]: 'client',
+ [SENTRY_ORIGIN]: ORIGIN,
+ [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_POSTGRES,
+ ...queryAttributes,
+ ...(context && _getConnectionAttributes(context)),
+ };
const span = startInactiveSpan({
- name,
- attributes: {
- [SENTRY_OP]: DB,
- [SENTRY_KIND]: 'client',
- [SENTRY_ORIGIN]: ORIGIN,
- [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_POSTGRES,
- [DB_QUERY_TEXT]: sanitizedSqlQuery,
- [DB_QUERY_SUMMARY]: querySummary,
- [DB_OPERATION_NAME]: _getOperationName(sanitizedSqlQuery),
- },
+ name: getSqlSpanName(attributes, 'postgresjs.query'),
+ onlyIfParent: requireParentSpan !== false,
+ attributes,
});
// Stash for the `execute`/`connect` channels to attach per-connection attributes.
(query as Record)[QUERY_SPAN] = span;
- if (context) {
- setConnectionAttributes(span, query, context);
- }
-
if (requestHook) {
try {
- requestHook(span, sanitizedSqlQuery, context);
+ requestHook(span, queryAttributes[DB_QUERY_TEXT] ?? '', context);
} catch (e) {
span.setAttribute('sentry.hook.error', 'requestHook failed');
DEBUG_BUILD && debug.error('[instrumentation:postgresjs] error in requestHook:', e);
}
}
- wrapQuerySettlement(data, span, sanitizedSqlQuery);
+ // Claim ownership of ending the span up front, so `deferSpanEnd` defers to the
+ // settlement wrappers even if `span.end()` throws in them.
+ _wrapQuerySettlement(query, span, queryAttributes[DB_OPERATION_NAME], () => {
+ data[SPAN_ENDED] = true;
+ });
return span;
},
{
- requiresParentSpan: requireParentSpan !== false,
deferSpanEnd({ data }) {
// `handle` is async: its promise settles on dispatch (asyncEnd), long
- // before the query does. The resolve/reject wrappers own the ending.
- if (data[SPAN_ENDED]) {
- return true; // wrappers already ended it
- }
- if ('error' in data) {
- return false; // `handle()` itself threw; the error subscriber annotated the span, let the helper end it
- }
+ // before the query does, so the resolve/reject wrappers own the ending.
+ // The helper only ends the span if `handle()` itself threw.
// NOTE: for a cursor consumed as an async iterator, only the first batch
// reaches `handle` (the `executed` guard blocks the rest), so the span
// ends on the first batch — a pre-existing flaw kept for parity.
- return true; // query in flight; the wrappers will end the span when it settles
+ return !!data[SPAN_ENDED] || !('error' in data);
},
},
);
}
/**
- * Diagnostics-channel-based postgres.js (`postgres` v3.x) integration.
+ * Diagnostics-channel-based postgres.js integration.
+ *
+ * - `postgres` 3.x: subscribes to the `orchestrion:postgres:handle` / `:connection` /
+ * `:execute` / `:connect` channels injected into postgres.js' `Query.prototype.handle`
+ * and `Connection`/`execute`/`connect` (in `src/*` and `cjs/src/*`). Requires the
+ * Sentry runtime hook or bundler plugin.
+ * - `postgres` >= 4.0.0: subscribes to the `postgres:query` tracing channel the library
+ * publishes natively.
*
- * Subscribes to the `orchestrion:postgres:handle` / `:connection` / `:execute` /
- * `:connect` diagnostics channels injected into postgres.js' `Query.prototype.handle`
- * and `Connection`/`execute`/`connect` (in `src/*` and `cjs/src/*`) and creates db
- * spans matching the OTel `postgresJsIntegration`. Requires the Sentry runtime
- * hook or bundler plugin.
+ * Both paths create the same db spans.
*/
export const postgresJsIntegration = defineIntegration(_postgresJsIntegration);
diff --git a/packages/server-utils/src/integrations/postgresjs/postgresjs-dc-subscriber.ts b/packages/server-utils/src/integrations/postgresjs/postgresjs-dc-subscriber.ts
new file mode 100644
index 000000000000..813607a441ef
--- /dev/null
+++ b/packages/server-utils/src/integrations/postgresjs/postgresjs-dc-subscriber.ts
@@ -0,0 +1,152 @@
+import type { TracingChannel } from 'node:diagnostics_channel';
+import {
+ DB_OPERATION_NAME,
+ DB_QUERY_TEXT,
+ DB_RESPONSE_STATUS_CODE,
+ DB_SYSTEM_NAME,
+ SENTRY_KIND,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
+} from '@sentry/conventions/attributes';
+import { DB } from '@sentry/conventions/op';
+import type { Span, SpanAttributes } from '@sentry/core';
+import { debug, getActiveSpan, isObjectLike, startInactiveSpan } from '@sentry/core';
+import { DEBUG_BUILD } from '../../debug-build';
+import { bindTracingChannelToSpan } from '../../tracing-channel';
+import { getSqlQueryAttributes, getSqlSpanName } from '../../utils/sql';
+import {
+ _getConnectionAttributes,
+ _isPostgresJsWrapperSpan,
+ type PostgresConnectionContext,
+ type PostgresJsSqlInstrumentationOptions,
+} from '../postgresjs-wrapper';
+
+/**
+ * Tracing channel published by `postgres` >= 4.0.0 for every query (see postgres `src/diagnostics.js`).
+ */
+export const POSTGRESJS_DC_CHANNEL_QUERY = 'postgres:query';
+
+const ORIGIN = 'auto.db.postgresjs';
+const DB_SYSTEM_NAME_POSTGRES = 'postgres';
+
+/**
+ * The context `postgres` >= 4.0.0 publishes on `postgres:query`. `host`, `port` and `connection`
+ * stay `null` until the query is written to a connection, and `command`/`count` until it completes,
+ * so they can only be read once the span ends.
+ */
+export interface PostgresJsQueryData {
+ query?: string | null;
+ database?: string;
+ user?: string | null;
+ host?: string | null;
+ port?: number | null;
+ command?: string | null;
+ result?: unknown;
+ error?: unknown;
+}
+
+/**
+ * Platform-provided factory for a native tracing channel. Node passes `node:diagnostics_channel`'s
+ * `tracingChannel` directly.
+ */
+export type PostgresJsTracingChannelFactory = (name: string) => TracingChannel;
+
+/**
+ * Subscribe Sentry span handlers to the `postgres:query` tracing channel published natively by
+ * `postgres` >= 4.0.0. Older versions never publish it, and the orchestrion transform is gated to
+ * `< 4`, so no query gets two spans.
+ */
+export function subscribePostgresJsDiagnosticChannels(
+ tracingChannel: PostgresJsTracingChannelFactory,
+ options: PostgresJsSqlInstrumentationOptions = {},
+): void {
+ const { requireParentSpan, requestHook } = options;
+
+ bindTracingChannelToSpan(
+ tracingChannel(POSTGRESJS_DC_CHANNEL_QUERY),
+ data => {
+ // `instrumentPostgresJsSql` already spans this query, and its span is active when postgres runs it.
+ if (_isPostgresJsWrapperSpan(getActiveSpan())) {
+ return undefined;
+ }
+
+ const queryAttributes = getSqlQueryAttributes(data.query ?? undefined);
+ const sanitizedSqlQuery = queryAttributes[DB_QUERY_TEXT] ?? '';
+ const connectionContext = getPostgresJsConnectionContext(data);
+
+ const attributes: SpanAttributes = {
+ [SENTRY_OP]: DB,
+ [SENTRY_KIND]: 'client',
+ [SENTRY_ORIGIN]: ORIGIN,
+ [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_POSTGRES,
+ ...queryAttributes,
+ ..._getConnectionAttributes(connectionContext),
+ };
+ const span = startInactiveSpan({
+ name: getSqlSpanName(attributes, 'postgresjs.query'),
+ onlyIfParent: requireParentSpan !== false,
+ attributes,
+ });
+
+ if (requestHook) {
+ callRequestHook(requestHook, span, sanitizedSqlQuery, connectionContext);
+ }
+
+ return span;
+ },
+ {
+ beforeSpanEnd(span, data) {
+ span.setAttributes(getPostgresJsQueryEndAttributes(data));
+ },
+ },
+ );
+}
+
+/**
+ * Maps a `postgres:query` context to a {@link PostgresConnectionContext}. At `start` only the
+ * database is known; `host`/`port` are filled in once the query is sent.
+ *
+ * @internal Exported for testing only
+ */
+export function getPostgresJsConnectionContext(data: PostgresJsQueryData): PostgresConnectionContext {
+ return {
+ ATTR_DB_NAMESPACE: data.database || undefined,
+ ATTR_SERVER_ADDRESS: data.host || undefined,
+ ATTR_SERVER_PORT: typeof data.port === 'number' ? String(data.port) : undefined,
+ };
+}
+
+/**
+ * Attributes only known once the query settled: the connection it ran on, the server-reported
+ * command and, on failure, the Postgres error code.
+ *
+ * @internal Exported for testing only
+ */
+export function getPostgresJsQueryEndAttributes(data: PostgresJsQueryData): SpanAttributes {
+ const attributes = _getConnectionAttributes(getPostgresJsConnectionContext(data));
+
+ if (data.command) {
+ attributes[DB_OPERATION_NAME] = data.command;
+ }
+
+ if ('error' in data) {
+ const code = isObjectLike(data.error) && 'code' in data.error ? data.error.code : undefined;
+ attributes[DB_RESPONSE_STATUS_CODE] = typeof code === 'string' && code ? code : 'unknown';
+ }
+
+ return attributes;
+}
+
+function callRequestHook(
+ requestHook: NonNullable,
+ span: Span,
+ sanitizedSqlQuery: string,
+ connectionContext: PostgresConnectionContext,
+): void {
+ try {
+ requestHook(span, sanitizedSqlQuery, connectionContext);
+ } catch (e) {
+ span.setAttribute('sentry.hook.error', 'requestHook failed');
+ DEBUG_BUILD && debug.error('[instrumentation:postgresjs] error in requestHook:', e);
+ }
+}
diff --git a/packages/server-utils/src/integrations/prisma/orchestrion.ts b/packages/server-utils/src/integrations/prisma/orchestrion.ts
index de209f694d71..a9c3bea5ac6d 100644
--- a/packages/server-utils/src/integrations/prisma/orchestrion.ts
+++ b/packages/server-utils/src/integrations/prisma/orchestrion.ts
@@ -52,11 +52,7 @@ export function instrumentPrismaV8(options: PrismaV8InstrumentationOptions): voi
bindTracingChannelToSpan(
diagnosticsChannel.tracingChannel(channelName),
data => startOperationSpan(method, data),
- {
- // A stray query outside a request must not become a segment of its own.
- requiresParentSpan: true,
- ...(isLazy ? { deferSpanEnd: deferSpanEndUntilConsumed } : {}),
- },
+ isLazy ? { deferSpanEnd: deferSpanEndUntilConsumed } : undefined,
);
}
}
@@ -72,6 +68,7 @@ function startOperationSpan(method: string, data: PrismaTerminalChannelContext):
const span = startInactiveSpan({
name: OPERATION_SPAN_NAME,
+ onlyIfParent: true,
parentSpan,
attributes: {
[SENTRY_ORIGIN]: PRISMA_ORIGIN,
diff --git a/packages/server-utils/src/integrations/prisma/tracing-helper.ts b/packages/server-utils/src/integrations/prisma/tracing-helper.ts
index 218e7c788134..96e37e8cb912 100644
--- a/packages/server-utils/src/integrations/prisma/tracing-helper.ts
+++ b/packages/server-utils/src/integrations/prisma/tracing-helper.ts
@@ -14,15 +14,7 @@
*/
import type { Span, SpanAttributes } from '@sentry/core';
-import {
- debug,
- getActiveSpan,
- getClient,
- hasSpanStreamingEnabled,
- LRUMap,
- startInactiveSpan,
- startSpanManual,
-} from '@sentry/core';
+import { debug, getActiveSpan, LRUMap, startInactiveSpan, startSpanManual } from '@sentry/core';
import { DEBUG_BUILD } from '../../debug-build';
import type { EngineSpan, ExtendedSpanOptions, SpanCallback, TracingHelper } from './types';
import {
@@ -35,7 +27,7 @@ import {
SENTRY_OP,
SENTRY_ORIGIN,
} from '@sentry/conventions/attributes';
-import { getSqlQuerySummary, sanitizeSqlQuery, type SqlDialect, toSqlDialect } from '../../utils/sql';
+import { getSqlQueryAttributes, getSqlSpanName, type SqlDialect, toSqlDialect } from '../../utils/sql';
// Reading `process.env` can throw in runtimes that gate env access (e.g. Deno without `--allow-env`)
// and `process` may be absent altogether (edge runtimes), so this degrades to `false` in those cases.
@@ -96,9 +88,17 @@ function registerPrismaSpan(id: string, span: Span): void {
* Older Prisma versions emit `prisma:engine:db_query` spans without a db system, so it's backfilled here.
*/
function buildSpanAttributes(name: string, attributes: Record | undefined): SpanAttributes {
+ const spanAttributes: SpanAttributes = (attributes as SpanAttributes) || {};
+ const statement = getSqlStatement(name, spanAttributes);
+ const queryAttributes = getSqlQueryAttributes(statement, getSqlDialect(spanAttributes));
+
const merged: SpanAttributes = {
- ...(attributes as SpanAttributes | undefined),
+ ...queryAttributes,
+ ...spanAttributes,
[SENTRY_ORIGIN]: PRISMA_ORIGIN,
+ // Always ours, so the summary (and the streamed span name built from it) is dialect-aware and
+ // consistent across Prisma versions, even if Prisma starts reporting its own.
+ [DB_QUERY_SUMMARY]: queryAttributes[DB_QUERY_SUMMARY],
};
// Prisma itself emits the deprecated `db.system` on older versions, so both spellings are checked
@@ -113,13 +113,6 @@ function buildSpanAttributes(name: string, attributes: Record |
merged[SENTRY_OP] = 'db';
}
- const statement = getSqlStatement(name, merged);
- if (statement) {
- // Sanitized before summarizing, so that a string literal containing `from`/`join` can't leak a
- // value into the summary.
- merged[DB_QUERY_SUMMARY] = getSqlQuerySummary(sanitizeSqlQuery(statement, getSqlDialect(merged)));
- }
-
return merged;
}
@@ -145,26 +138,23 @@ function getSqlStatement(name: string, attributes: SpanAttributes): string | und
return dbStatement;
}
const queryText = attributes[DB_QUERY_TEXT];
- if ((name === 'prisma:engine:db_query' || name === 'prisma:client:db_query') && typeof queryText === 'string') {
+ if (isDbQuerySpan(name) && typeof queryText === 'string') {
return queryText;
}
return undefined;
}
/**
- * Db query spans are named after their SQL text (e.g. `SELECT * FROM "User"`) rather than the generic
- * engine name. v5/v6 emit `prisma:engine:db_query`; v7 inlined the engine and emits `prisma:client:db_query`.
+ * Db query spans are named like every other SQL span (see `getSqlSpanName`), falling back to the
+ * Prisma span name. v5/v6 emit `prisma:engine:db_query`; v7 inlined the engine and emits
+ * `prisma:client:db_query`. Other Prisma spans keep their own name, even when they carry db attributes.
*/
function buildSpanName(name: string, attributes: SpanAttributes): string {
- const client = getClient();
-
- // With span streaming, span names have to be low cardinality, so `{db.query.summary}` is used
- // instead of the full statement. Spans that report no SQL keep the engine span name.
- if (client && hasSpanStreamingEnabled(client)) {
- return (attributes[DB_QUERY_SUMMARY] as string | undefined) || name;
- }
+ return isDbQuerySpan(name) ? getSqlSpanName(attributes, name) : name;
+}
- return getSqlStatement(name, attributes) ?? name;
+function isDbQuerySpan(name: string): boolean {
+ return name === 'prisma:engine:db_query' || name === 'prisma:client:db_query';
}
/**
diff --git a/packages/server-utils/src/integrations/redis/ioredis-channel-subscriber.ts b/packages/server-utils/src/integrations/redis/ioredis-channel-subscriber.ts
index d918d080f749..9426e26c6add 100644
--- a/packages/server-utils/src/integrations/redis/ioredis-channel-subscriber.ts
+++ b/packages/server-utils/src/integrations/redis/ioredis-channel-subscriber.ts
@@ -93,6 +93,7 @@ export function startIORedisCommandSpan(
return startInactiveSpan({
name: cacheProperties?.name ?? streamedName ?? statement,
+ onlyIfParent: true,
attributes: { ...attributes, ...cacheProperties?.attributes },
});
}
@@ -112,8 +113,6 @@ export function instrumentIoredis(options: RedisCacheOptions): void {
);
bindTracingChannelToSpan(commandChannel, data => startIORedisCommandSpan(data, options), {
- // ioredis' `requireParentSpan` default: only create a span under an active span.
- requiresParentSpan: true,
beforeSpanEnd(span, data) {
if (!('error' in data)) {
applyCacheResponseAttributes(span, data.result);
@@ -121,19 +120,16 @@ export function instrumentIoredis(options: RedisCacheOptions): void {
},
});
- bindTracingChannelToSpan(
- connectChannel,
- data => {
- const { host, port } = getConnectionOptions(data.self);
- return startInactiveSpan({
- name: 'connect',
- attributes: {
- [SENTRY_KIND]: 'client',
- ...connectionAttributes(host, port),
- [SENTRY_OP]: DB,
- },
- });
- },
- { requiresParentSpan: true },
- );
+ bindTracingChannelToSpan(connectChannel, data => {
+ const { host, port } = getConnectionOptions(data.self);
+ return startInactiveSpan({
+ name: 'connect',
+ onlyIfParent: true,
+ attributes: {
+ [SENTRY_KIND]: 'client',
+ ...connectionAttributes(host, port),
+ [SENTRY_OP]: DB,
+ },
+ });
+ });
}
diff --git a/packages/server-utils/src/integrations/tedious.ts b/packages/server-utils/src/integrations/tedious.ts
index 0878dc5e3118..24c2b1547859 100644
--- a/packages/server-utils/src/integrations/tedious.ts
+++ b/packages/server-utils/src/integrations/tedious.ts
@@ -14,6 +14,7 @@ import {
} from '@sentry/core';
import {
DB_NAMESPACE,
+ DB_OPERATION_NAME,
DB_QUERY_SUMMARY,
DB_QUERY_TEXT,
DB_SYSTEM_NAME,
@@ -29,7 +30,7 @@ import type { OrchestrionChannelContext } from '../orchestrion/types';
import { CHANNELS } from '../orchestrion/channels';
import { tediousModuleNames } from '../orchestrion/config/tedious';
import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation';
-import { getSqlQuerySummary, sanitizeSqlQuery } from '../utils/sql';
+import { getSqlQueryAttributes } from '../utils/sql';
// NOTE: this uses the same name as the OTel integration by design. When orchestrion injection is active,
// `_init` swaps the OTel `Tedious` integration out of the defaults and appends this one (matched by name).
@@ -131,8 +132,12 @@ function subscribeQuery(channelName: string, operation: string): void {
const databaseName = connection[currentDatabaseSymbol];
const sql = extractSql(request);
- const queryText = sql ? sanitizeSqlQuery(sql, 'mssql') : undefined;
- const querySummary = queryText && operation !== 'callProcedure' ? getSqlQuerySummary(queryText) : undefined;
+ const queryAttributes = getSqlQueryAttributes(sql, 'mssql');
+ if (operation === 'callProcedure') {
+ // `sql` is the procedure name here rather than a statement, so there is nothing to summarize.
+ queryAttributes[DB_QUERY_SUMMARY] = undefined;
+ queryAttributes[DB_OPERATION_NAME] = undefined;
+ }
const attributes: SpanAttributes = {
[SENTRY_OP]: DB,
@@ -142,21 +147,25 @@ function subscribeQuery(channelName: string, operation: string): void {
[DB_NAMESPACE]: databaseName,
// `>=4` uses the `authentication` object; older versions expose `userName` directly.
[DB_USER]: connection.config?.userName ?? connection.config?.authentication?.options?.userName,
- [DB_QUERY_TEXT]: queryText,
- [DB_QUERY_SUMMARY]: querySummary,
+ ...queryAttributes,
[ATTR_DB_SQL_TABLE]: request.table,
[SERVER_ADDRESS]: connection.config?.server,
[SERVER_PORT]: connection.config?.options?.port,
};
+ // Not `getSqlSpanName`: bulk loads and procedure calls carry no statement, and naming them
+ // after the operation (`execBulkLoad `, `callProcedure `) says more than the
+ // namespace or db system it would fall back to.
const client = getClient();
const span = startInactiveSpan({
name:
client && hasSpanStreamingEnabled(client)
- ? querySummary || getLowCardinalitySecondarySpanName(operation, databaseName, sql, request.table)
- : queryText || getSecondarySpanName(operation, databaseName, request.table),
+ ? queryAttributes[DB_QUERY_SUMMARY] ||
+ getLowCardinalitySecondarySpanName(operation, databaseName, sql, request.table)
+ : queryAttributes[DB_QUERY_TEXT] || getSecondarySpanName(operation, databaseName, request.table),
attributes,
+ onlyIfParent: true,
});
const endSpan = once((err?: { message?: string }): void => {
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 2943aa7dddb2..c1daaf73fc84 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
@@ -86,6 +86,8 @@ const VERCEL_AI_OPERATION_ID_ATTRIBUTE = 'vercel.ai.operationId';
const VERCEL_AI_MODEL_PROVIDER_ATTRIBUTE = 'vercel.ai.model.provider';
const VERCEL_AI_SETTINGS_MAX_RETRIES_ATTRIBUTE = 'vercel.ai.settings.maxRetries';
const VERCEL_AI_TELEMETRY_METADATA_ATTRIBUTE_PREFIX = 'vercel.ai.telemetry.metadata.';
+const VERCEL_AI_SETTINGS_CONTEXT_ATTRIBUTE_PREFIX = 'vercel.ai.settings.context.';
+const VERCEL_AI_TOOL_CALL_CONTEXT_ATTRIBUTE_PREFIX = 'vercel.ai.tool_call.context.';
// Tracks the top-level operationId (and whether it streams) per `callId` so a model-call span can
// name its `doGenerate`/`doStream` operation the same way the OTel integration does. `isStream` is
@@ -108,6 +110,10 @@ const toolDescriptionsByCallId = new Map>();
// before the operation's own span ends. The running token sum lives on the span's own attributes.
const invokeAgentSpanByCallId = new Map();
+// Only an operation's start event carries `runtimeContext`; its model-call and tool events copy the
+// resulting attributes from here. Cleared with the operation.
+const runtimeContextAttributesByCallId = new Map();
+
// Only top-level operations own the `callId` → operationId mapping; `step`/`languageModelCall`/
// `executeTool` share the parent's `callId`, so they must not clear it.
const ROOT_OPERATION_TYPES = new Set([
@@ -140,6 +146,7 @@ export function clearOperationCallId(callId: string): void {
operationIdByCallId.delete(callId);
toolDescriptionsByCallId.delete(callId);
invokeAgentSpanByCallId.delete(callId);
+ runtimeContextAttributesByCallId.delete(callId);
}
/**
@@ -450,13 +457,22 @@ export function createSpanFromMessage(
getOpenAiConversationId(event.providerOptions) ??
(ROOT_OPERATION_TYPES.has(type) ? undefined : getActiveSpanConversationId());
+ const runtimeContextAttributes =
+ (callId ? runtimeContextAttributesByCallId.get(callId) : undefined) ??
+ contextAttributes(event, event.runtimeContext, VERCEL_AI_SETTINGS_CONTEXT_ATTRIBUTE_PREFIX);
+ if (callId && ROOT_OPERATION_TYPES.has(type)) {
+ runtimeContextAttributesByCallId.set(callId, runtimeContextAttributes);
+ }
+
const baseAttributes: SpanAttributes = {
[SENTRY_ORIGIN]: ORIGIN,
...telemetryMetadataAttributes(event.telemetryMetadata),
- ...(provider ? { [GEN_AI_PROVIDER_NAME]: provider, [VERCEL_AI_MODEL_PROVIDER_ATTRIBUTE]: provider } : {}),
- ...(modelId ? { [GEN_AI_REQUEST_MODEL]: modelId } : {}),
- ...(maxRetries !== undefined ? { [VERCEL_AI_SETTINGS_MAX_RETRIES_ATTRIBUTE]: maxRetries } : {}),
- ...(conversationId ? { [GEN_AI_CONVERSATION_ID]: conversationId } : {}),
+ ...runtimeContextAttributes,
+ [GEN_AI_PROVIDER_NAME]: provider,
+ [VERCEL_AI_MODEL_PROVIDER_ATTRIBUTE]: provider,
+ [GEN_AI_REQUEST_MODEL]: modelId,
+ [VERCEL_AI_SETTINGS_MAX_RETRIES_ATTRIBUTE]: maxRetries,
+ [GEN_AI_CONVERSATION_ID]: conversationId,
};
switch (type) {
@@ -472,7 +488,7 @@ export function createSpanFromMessage(
return buildModelCallSpan(event, baseAttributes, recordInputs, callId, modelId);
case 'executeTool':
- return buildToolSpan(event, recordInputs, conversationId);
+ return buildToolSpan(event, recordInputs, conversationId, runtimeContextAttributes);
case 'embed':
case 'embedMany': {
// `embed` carries a single `value`; `embedMany` a `values` array — both map to the embeddings input.
@@ -519,6 +535,26 @@ function telemetryMetadataAttributes(metadata: unknown): Record
return attributes;
}
+/**
+ * One string attribute per context key, as ``. `ai` < 7.0.114 published the full context to the
+ * channel, ignoring `telemetry.includeRuntimeContext`/`includeToolsContext`, and a filtered context
+ * looks the same as an unfiltered one. `aiVersion` (added in 7.0.154) is the only reliable sign that
+ * the AI SDK filtered it, so without it we record nothing.
+ */
+function contextAttributes(event: Record, context: unknown, prefix: string): SpanAttributes {
+ const attributes: SpanAttributes = {};
+ if (typeof event.aiVersion !== 'string' || !isObjectLike(context)) {
+ return attributes;
+ }
+ for (const [key, value] of Object.entries(context)) {
+ const attributeValue = stringify(value);
+ if (value !== null && attributeValue !== undefined) {
+ attributes[`${prefix}${key}`] = attributeValue;
+ }
+ }
+ return attributes;
+}
+
/** Start a `gen_ai.` span named ` ` (or just `` when no suffix). */
function startGenAiSpan(operation: GenAiOperation, suffix: string | undefined, attributes: SpanAttributes): Span {
return startInactiveSpan({
@@ -547,7 +583,7 @@ function buildInvokeAgentSpan(
...baseAttributes,
[VERCEL_AI_OPERATION_ID_ATTRIBUTE]: operationId,
[GEN_AI_RESPONSE_STREAMING]: isStream,
- ...(functionId ? { [GEN_AI_FUNCTION_ID]: functionId } : {}),
+ [GEN_AI_FUNCTION_ID]: functionId,
...(recordInputs ? buildInputMessageAttributes(event) : {}),
});
if (isStream && callId) {
@@ -580,6 +616,7 @@ function buildToolSpan(
event: Record,
recordInputs: boolean,
conversationId: string | undefined,
+ runtimeContextAttributes: SpanAttributes,
): Span {
const toolCall = isObjectLike(event.toolCall) ? event.toolCall : {};
const toolName = asString(toolCall.toolName);
@@ -591,11 +628,13 @@ function buildToolSpan(
recordInputs && toolName ? resolveToolDescription(asString(event.callId), toolName, event.tools) : undefined;
return startGenAiSpan('execute_tool', toolName, {
[SENTRY_ORIGIN]: ORIGIN,
- ...(toolName ? { [GEN_AI_TOOL_NAME]: toolName } : {}),
- ...(toolCallId ? { [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: toolCallId } : {}),
- ...(description ? { [GEN_AI_TOOL_DESCRIPTION]: description } : {}),
+ ...runtimeContextAttributes,
+ ...contextAttributes(event, event.toolContext, VERCEL_AI_TOOL_CALL_CONTEXT_ATTRIBUTE_PREFIX),
+ [GEN_AI_TOOL_NAME]: toolName,
+ [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: toolCallId,
+ [GEN_AI_TOOL_DESCRIPTION]: description,
...(recordInputs && toolInput !== undefined ? { [GEN_AI_TOOL_CALL_ARGUMENTS]: stringify(toolInput) } : {}),
- ...(conversationId ? { [GEN_AI_CONVERSATION_ID]: conversationId } : {}),
+ [GEN_AI_CONVERSATION_ID]: conversationId,
});
}
diff --git a/packages/server-utils/src/orchestrion/bundler/options.ts b/packages/server-utils/src/orchestrion/bundler/options.ts
index ad6439cd5962..0d9aec844d59 100644
--- a/packages/server-utils/src/orchestrion/bundler/options.ts
+++ b/packages/server-utils/src/orchestrion/bundler/options.ts
@@ -36,6 +36,13 @@ export type PluginOptions = {
* routing every channel through it is harmless.
*/
dcModule?: string;
+ /**
+ * When `true`, the Vite plugin also runs the transform in the dependency optimizer of server environments in
+ * `vite dev`. Needed for `@cloudflare/vite-plugin`, which pre-bundles the dependencies of a Worker there.
+ *
+ * @default false
+ */
+ transformOptimizedDeps?: boolean;
};
/**
diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts
index 16e642dc76fd..d1a40295c9ee 100644
--- a/packages/server-utils/src/orchestrion/bundler/vite.ts
+++ b/packages/server-utils/src/orchestrion/bundler/vite.ts
@@ -1,33 +1,34 @@
+import esbuildCodeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/esbuild';
import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/vite';
-import type { ConfigEnv, Plugin, ResolvedConfig, UserConfig } from 'vite';
+import type { Plugin as EsbuildPlugin } from 'esbuild';
+import type { ConfigEnv, ConfigPluginContext, EnvironmentOptions, Plugin, ResolvedConfig, UserConfig } from 'vite';
export type { Plugin as VitePlugin } from 'vite';
import { instrumentedModuleNames } from '../config';
import type { PluginOptions } from './options';
import { externalEntryMatchesModule, externalizedModulesWarning, orchestrionTransformOptions } from './options';
import { createExternalRequireResolver } from './externalRequire';
-import { resolveOrchestrionRuntimeRequest, SNIPPET_IMPORT_SPECIFIER } from './resolve';
+import { resolveOrchestrionRuntimeRequest, SNIPPET_IMPORT_SPECIFIER, SNIPPET_IMPORT_SPECIFIER_FILTER } from './resolve';
type TransformHandler = (this: unknown, code: string, id: string, opts?: { ssr?: boolean }) => unknown;
+type ResolveIdHandler = Extract, (...args: never[]) => unknown>;
// Key of a property on the plugin object. Its value is an object that is unique to each `sentryOrchestrionPlugin()`
// call. An SDK that wraps the plugin with an object spread (`@sentry/remix`) copies the property, so `configResolved`
// can find the first instance in a build also through a wrapper.
const INSTANCE_KEY = '__sentryOrchestrionInstance';
-type OrchestrionVitePlugin = Plugin & { [INSTANCE_KEY]?: object };
+type OrchestrionInstance = { transformOptimizedDeps: boolean };
-// On Vite >= 6 `applyToEnvironment` (below) keeps the whole plugin out of
-// client environments. Vite 5 (e.g. Remix v2) ignores that hook, so without
-// this gate the transform would also run in the CLIENT build — where modules
-// like `@remix-run/server-runtime` sit in the client graph, and the injected
-// snippet's import of the subscriber factories (which import
-// `node:diagnostics_channel`) breaks against Vite's browser builtin shim. Gate
-// on the `ssr` flag, which Vite passes on both major versions.
-function ssrOnlyTransform(transform: Plugin['transform'], isDuplicate: () => boolean): Plugin['transform'] {
+type OrchestrionVitePlugin = Plugin & { [INSTANCE_KEY]?: OrchestrionInstance };
+
+function gateTransform(
+ transform: Plugin['transform'],
+ skip: (opts?: { ssr?: boolean }) => boolean,
+): Plugin['transform'] {
const gate = (handler: TransformHandler): TransformHandler =>
function (code, id, opts) {
- if (!opts?.ssr || isDuplicate()) {
+ if (skip(opts)) {
return null;
}
return handler.call(this, code, id, opts);
@@ -47,6 +48,74 @@ function toEsmEntry(id: string): string {
return id.replace(/([/\\])build[/\\]cjs[/\\]index\.js$/, '$1build$1esm$1index.js');
}
+// The module-injected snippet imports `@sentry/server-utils` from INSIDE
+// transformed `node_modules` files. Under isolated installs (pnpm) that bare
+// specifier doesn't resolve from an instrumented package's location, so when
+// normal resolution fails, fall back to this package's own resolution so it
+// gets bundled from its real on-disk path.
+//
+// Always bundle the ESM entry. In a CommonJS module the snippet `require()`s the specifier,
+// which resolves to the CJS build. Vite 5-7 turn that build's `require()`s of external
+// packages into default imports, and `@sentry/core` has no default export, so the server
+// crashes at startup. Vite 8 keeps the `require()`s, which loads a second copy of `@sentry/core`.
+async function resolveSnippetImport(
+ this: ThisParameterType,
+ ...[source, importer, resolveOptions]: Parameters
+): Promise>> {
+ const resolved = await this.resolve(source, importer, { ...resolveOptions, skipSelf: true });
+ if (resolved) {
+ return resolved.external ? resolved : { ...resolved, id: toEsmEntry(resolved.id) };
+ }
+ const fallback = resolveOrchestrionRuntimeRequest(source);
+ return fallback ? toEsmEntry(fallback) : null;
+}
+
+// Rolldown plugin for the dependency optimizer of Vite 8.
+function depOptimizerRolldownPlugin(options: PluginOptions, isDuplicate: () => boolean): Plugin {
+ const upstream = codeTransformer(orchestrionTransformOptions(options));
+ return {
+ ...upstream,
+ name: 'sentry-orchestrion-dep-optimizer',
+ transform: gateTransform(upstream.transform, isDuplicate),
+ resolveId(source, importer, resolveOptions) {
+ return source === SNIPPET_IMPORT_SPECIFIER
+ ? resolveSnippetImport.call(this, source, importer, resolveOptions)
+ : null;
+ },
+ };
+}
+
+// esbuild plugin for the dependency optimizer of Vite 6 and 7.
+function depOptimizerEsbuildPlugin(options: PluginOptions, isDuplicate: () => boolean): EsbuildPlugin {
+ const upstream = esbuildCodeTransformer(orchestrionTransformOptions(options));
+ return {
+ name: 'sentry-orchestrion-dep-optimizer',
+ setup(build) {
+ if (isDuplicate()) {
+ return;
+ }
+ // Same resolution as `resolveSnippetImport`. The `pluginData` marker stops the recursion back into this callback.
+ build.onResolve({ filter: SNIPPET_IMPORT_SPECIFIER_FILTER }, async args => {
+ if (args.pluginData === 'sentry-orchestrion-resolving') {
+ return null;
+ }
+ const resolved = await build.resolve(args.path, {
+ resolveDir: args.resolveDir,
+ importer: args.importer,
+ kind: args.kind,
+ pluginData: 'sentry-orchestrion-resolving',
+ });
+ if (resolved.errors.length === 0) {
+ return resolved.external ? resolved : { ...resolved, path: toEsmEntry(resolved.path) };
+ }
+ const fallback = resolveOrchestrionRuntimeRequest(args.path);
+ return fallback ? { path: toEsmEntry(fallback) } : null;
+ });
+ return upstream.setup(build);
+ },
+ };
+}
+
/**
* Vite plugin that runs the orchestrion code transform on the bundled output.
*
@@ -76,38 +145,30 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin {
];
const resolveExternalRequire = createExternalRequireResolver(noExternalModules());
- const instance = {};
+ const instance: OrchestrionInstance = { transformOptimizedDeps: options.transformOptimizedDeps === true };
// `true` when an earlier instance of this plugin is in the same build, which then transforms the modules alone.
let isDuplicate = false;
+ // `true` when an earlier instance with `transformOptimizedDeps` is in the same build, which then transforms the
+ // pre-bundled dependencies alone.
+ let isDuplicateOptimizer = false;
const plugin: OrchestrionVitePlugin = {
...upstream,
[INSTANCE_KEY]: instance,
- transform: ssrOnlyTransform(upstream.transform, () => isDuplicate),
- // The module-injected snippet imports `@sentry/server-utils` from INSIDE
- // transformed `node_modules` files. Under isolated installs (pnpm) that bare
- // specifier doesn't resolve from an instrumented package's location, so when
- // normal resolution fails, fall back to this package's own resolution so it
- // gets bundled from its real on-disk path. SSR-gated like the transform: the
- // specifier only appears in SSR modules.
- //
- // Always bundle the ESM entry. In a CommonJS module the snippet `require()`s the specifier,
- // which resolves to the CJS build. Vite 5-7 turn that build's `require()`s of external
- // packages into default imports, and `@sentry/core` has no default export, so the server
- // crashes at startup. Vite 8 keeps the `require()`s, which loads a second copy of `@sentry/core`.
+ // On Vite >= 6 `applyToEnvironment` (below) keeps the whole plugin out of
+ // client environments. Vite 5 (e.g. Remix v2) ignores that hook, so without
+ // this gate the transform would also run in the CLIENT build — where modules
+ // like `@remix-run/server-runtime` sit in the client graph, and the injected
+ // snippet's import of the subscriber factories (which import
+ // `node:diagnostics_channel`) breaks against Vite's browser builtin shim. Gate
+ // on the `ssr` flag, which Vite passes on both major versions.
+ transform: gateTransform(upstream.transform, opts => !opts?.ssr || isDuplicate),
+ // SSR-gated like the transform: the specifier only appears in SSR modules.
async resolveId(source, importer, resolveOptions) {
if (source !== SNIPPET_IMPORT_SPECIFIER) {
return resolveExternalRequire.call(this, source, importer, resolveOptions);
}
- if (!resolveOptions?.ssr) {
- return null;
- }
- const resolved = await this.resolve(source, importer, { ...resolveOptions, skipSelf: true });
- if (resolved) {
- return resolved.external ? resolved : { ...resolved, id: toEsmEntry(resolved.id) };
- }
- const fallback = resolveOrchestrionRuntimeRequest(source);
- return fallback ? toEsmEntry(fallback) : null;
+ return resolveOptions?.ssr ? resolveSnippetImport.call(this, source, importer, resolveOptions) : null;
},
applyToEnvironment(environment) {
// Orchestrion splices `node:diagnostics_channel` calls into instrumented modules, which only
@@ -136,21 +197,50 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin {
},
},
configEnvironment(
+ // Vite 6 calls this hook without a context.
+ this: ConfigPluginContext | undefined,
name: string,
config: { consumer?: 'client' | 'server' },
env?: ConfigEnv,
- ): { resolve: { noExternal: string[] } } | null {
- if (env?.command === 'serve' || (config.consumer ?? (name === 'client' ? 'client' : 'server')) !== 'server') {
+ ): EnvironmentOptions | null {
+ if ((config.consumer ?? (name === 'client' ? 'client' : 'server')) !== 'server') {
return null;
}
+ // `@cloudflare/vite-plugin` pre-bundles the dependencies of a Worker in `serve`. The dependency
+ // optimizer runs no Vite plugins, so the transform has to run inside it. A Node server loads its
+ // dependencies with Node, where the runtime hook instruments them.
+ if (env?.command === 'serve') {
+ if (!options.transformOptimizedDeps) {
+ return null;
+ }
+ if (this?.meta?.rolldownVersion) {
+ return {
+ optimizeDeps: {
+ rolldownOptions: { plugins: [depOptimizerRolldownPlugin(options, () => isDuplicateOptimizer)] },
+ },
+ };
+ }
+ return {
+ optimizeDeps: {
+ // Vite 6 and 7 have no `rolldownOptions`.
+ // oxlint-disable-next-line typescript/no-deprecated
+ esbuildOptions: { plugins: [depOptimizerEsbuildPlugin(options, () => isDuplicateOptimizer)] },
+ },
+ };
+ }
+
return { resolve: { noExternal: noExternalModules() } };
},
configResolved(config: ResolvedConfig): void {
// Two Sentry Vite plugins can each add this plugin to one build, for example `sentryCloudflareVitePlugin` and
// `sentryReactRouter`. A module transformed twice declares the injected snippet twice and fails the build.
- const first = (config.plugins as readonly OrchestrionVitePlugin[]).find(p => p[INSTANCE_KEY]);
- isDuplicate = first !== undefined && first[INSTANCE_KEY] !== instance;
+ const instances = (config.plugins as readonly OrchestrionVitePlugin[]).flatMap(p => p[INSTANCE_KEY] ?? []);
+ isDuplicate = instances[0] !== undefined && instances[0] !== instance;
+ // The first instance can be one without `transformOptimizedDeps`, for example the one of `sentryReactRouter`
+ // before `sentryCloudflareVitePlugin`.
+ const firstOptimizer = instances.find(i => i.transformOptimizedDeps);
+ isDuplicateOptimizer = firstOptimizer !== undefined && firstOptimizer !== instance;
// Nothing is force-bundled in `serve`, so an externalized module is expected there.
if (isDuplicate || config.command === 'serve') {
diff --git a/packages/server-utils/src/orchestrion/bundler/webpack.ts b/packages/server-utils/src/orchestrion/bundler/webpack.ts
index 4aed6df60460..dec13a661026 100644
--- a/packages/server-utils/src/orchestrion/bundler/webpack.ts
+++ b/packages/server-utils/src/orchestrion/bundler/webpack.ts
@@ -16,8 +16,11 @@ import { externalEntryMatchesModule, externalizedModulesWarning, orchestrionTran
// Explicitly annotated with the vendored types so the emitted declaration doesn't reference
// `@apm-js-collab/code-transformer-bundler-plugins` — a bundled devDependency consumers don't have.
-export const serializeInstrumentations: (configs: AnyInstrumentationConfig[]) => SerializableInstrumentationConfig[] =
- serializeInstrumentationsImpl;
+// Upstream also allows a function in `transform`. The vendored configs only allow a string there,
+// and the serializer copies `transform` unchanged, so its output only contains strings too.
+export const serializeInstrumentations = serializeInstrumentationsImpl as (
+ configs: AnyInstrumentationConfig[],
+) => SerializableInstrumentationConfig[];
export type { SerializableInstrumentationConfig } from '../apmTypes';
export { getOrchestrionLoaderPath, resolveOrchestrionRuntimeRequest };
diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts
index b5919b9eb071..f593fefe0ebd 100644
--- a/packages/server-utils/src/orchestrion/channels.ts
+++ b/packages/server-utils/src/orchestrion/channels.ts
@@ -1,6 +1,7 @@
import { amqplibChannels } from './config/amqplib';
import { anthropicAiChannels } from './config/anthropic-ai';
import { awsSdkChannels } from './config/aws-sdk';
+import { cronChannels } from './config/cron';
import { dataloaderChannels } from './config/dataloader';
import { expressChannels } from './config/express';
import { firebaseChannels } from './config/firebase';
@@ -26,6 +27,8 @@ import { mysql2Channels } from './config/mysql2';
import { mysqlChannels } from './config/mysql';
import { neonChannels } from './config/neon';
import { nestjsChannels } from './config/nestjs';
+import { nodeCronChannels } from './config/node-cron';
+import { nodeScheduleChannels } from './config/node-schedule';
import { openaiChannels } from './config/openai';
import { pgChannels } from './config/pg';
import { piDurableChannels } from './config/pi-durable';
@@ -58,6 +61,7 @@ export const CHANNELS = {
...amqplibChannels,
...anthropicAiChannels,
...awsSdkChannels,
+ ...cronChannels,
...dataloaderChannels,
...expressChannels,
...firebaseChannels,
@@ -83,6 +87,8 @@ export const CHANNELS = {
...mysqlChannels,
...neonChannels,
...nestjsChannels,
+ ...nodeCronChannels,
+ ...nodeScheduleChannels,
...openaiChannels,
...pgChannels,
...piDurableChannels,
diff --git a/packages/server-utils/src/orchestrion/config/anthropic-ai.ts b/packages/server-utils/src/orchestrion/config/anthropic-ai.ts
index bb7adcae14b2..f0a953e03ef1 100644
--- a/packages/server-utils/src/orchestrion/config/anthropic-ai.ts
+++ b/packages/server-utils/src/orchestrion/config/anthropic-ai.ts
@@ -6,9 +6,10 @@ export const anthropicAiConfig = [
// One entry each for CJS/ESM
...['resources/messages/messages.js', 'resources/messages/messages.mjs'].map(filePath => ({
channelName: 'chat',
- module: { name: '@anthropic-ai/sdk', versionRange: '>=0.19.2 <1', filePath },
+ module: { name: '@anthropic-ai/sdk', versionRange: '>=0.19.2 <2', filePath },
functionQuery: { className: 'Messages', methodName: 'create', kind: 'Sync' as const },
})),
+ // Removed in 1.0.
...['resources/completions.js', 'resources/completions.mjs'].map(filePath => ({
channelName: 'chat',
module: { name: '@anthropic-ai/sdk', versionRange: '>=0.19.2 <1', filePath },
@@ -16,25 +17,25 @@ export const anthropicAiConfig = [
})),
...['resources/beta/messages/messages.js', 'resources/beta/messages/messages.mjs'].map(filePath => ({
channelName: 'chat',
- module: { name: '@anthropic-ai/sdk', versionRange: '>=0.19.2 <1', filePath },
+ module: { name: '@anthropic-ai/sdk', versionRange: '>=0.19.2 <2', filePath },
functionQuery: { className: 'Messages', methodName: 'create', kind: 'Sync' as const },
})),
// `messages.stream()` returns a synchronous emitter, not a promise, so `kind: 'Sync'` is required:
// `Auto`'s promise wrapper never publishes `end` for a non-thenable return, so the span would never end.
...['resources/messages/messages.js', 'resources/messages/messages.mjs'].map(filePath => ({
channelName: 'messages-stream',
- module: { name: '@anthropic-ai/sdk', versionRange: '>=0.19.2 <1', filePath },
+ module: { name: '@anthropic-ai/sdk', versionRange: '>=0.19.2 <2', filePath },
functionQuery: { className: 'Messages', methodName: 'stream', kind: 'Sync' as const },
})),
...['resources/beta/messages/messages.js', 'resources/beta/messages/messages.mjs'].map(filePath => ({
channelName: 'tool-runner',
- module: { name: '@anthropic-ai/sdk', versionRange: '>=0.63.0 <1', filePath },
+ module: { name: '@anthropic-ai/sdk', versionRange: '>=0.63.0 <2', 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 },
+ module: { name: '@anthropic-ai/sdk', versionRange: '>=0.97.0 <2', filePath },
functionQuery: { className: 'SessionToolRunner' },
})),
] satisfies InstrumentationConfig[];
diff --git a/packages/server-utils/src/orchestrion/config/cron.ts b/packages/server-utils/src/orchestrion/config/cron.ts
new file mode 100644
index 000000000000..499d224aad9e
--- /dev/null
+++ b/packages/server-utils/src/orchestrion/config/cron.ts
@@ -0,0 +1,23 @@
+import type { InstrumentationConfig } from '../apmTypes';
+import { getModuleNames } from './module-names';
+
+// `CronJob.from()` calls the constructor. Job names were added in 4.3.0.
+export const cronConfig = [
+ {
+ channelName: 'cronJobConstructor',
+ module: { name: 'cron', versionRange: '>=4.3.0 <5', filePath: 'dist/job.js' },
+ functionQuery: { className: 'CronJob' },
+ },
+ {
+ channelName: 'cronJobSetTime',
+ module: { name: 'cron', versionRange: '>=4.3.0 <5', filePath: 'dist/job.js' },
+ functionQuery: { className: 'CronJob', methodName: 'setTime', kind: 'Sync' },
+ },
+] satisfies InstrumentationConfig[];
+
+export const cronModuleNames = getModuleNames(cronConfig);
+
+export const cronChannels = {
+ CRON_JOB_CONSTRUCTOR: 'orchestrion:cron:cronJobConstructor',
+ CRON_JOB_SET_TIME: 'orchestrion:cron:cronJobSetTime',
+} as const;
diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts
index e60810315420..7636fbef80fc 100644
--- a/packages/server-utils/src/orchestrion/config/index.ts
+++ b/packages/server-utils/src/orchestrion/config/index.ts
@@ -5,6 +5,7 @@ import { uniq } from '@sentry/core';
import { awsSdkConfig } from './aws-sdk';
import { amqplibConfig } from './amqplib';
import { anthropicAiConfig } from './anthropic-ai';
+import { cronConfig } from './cron';
import { dataloaderConfig } from './dataloader';
import { expressConfig } from './express';
import { firebaseConfig } from './firebase';
@@ -33,6 +34,8 @@ import { mysqlConfig } from './mysql';
import { neonConfig } from './neon';
import { nestjsConfig } from './nestjs';
import { nitroConfig } from './nitro';
+import { nodeCronConfig } from './node-cron';
+import { nodeScheduleConfig } from './node-schedule';
import { openaiConfig } from './openai';
import { pgConfig } from './pg';
import { postgresJsConfig } from './postgres';
@@ -64,6 +67,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [
...amqplibConfig,
...anthropicAiConfig,
...awsSdkConfig,
+ ...cronConfig,
...dataloaderConfig,
...expressConfig,
...firebaseConfig,
@@ -91,6 +95,8 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [
...neonConfig,
...nestjsConfig,
...nitroConfig,
+ ...nodeCronConfig,
+ ...nodeScheduleConfig,
...openaiConfig,
...pgConfig,
...piDurableConfig,
diff --git a/packages/server-utils/src/orchestrion/config/mistral.ts b/packages/server-utils/src/orchestrion/config/mistral.ts
index 01dd2b7320ff..aefaafb01c1d 100644
--- a/packages/server-utils/src/orchestrion/config/mistral.ts
+++ b/packages/server-utils/src/orchestrion/config/mistral.ts
@@ -2,10 +2,10 @@ import type { InstrumentationConfig } from '../apmTypes';
import { getModuleNames } from './module-names';
-// `@mistralai/mistralai` v2 is ESM-only, so there is a single built file per resource (no dual CJS/ESM
+// `@mistralai/mistralai` v2+ is ESM-only, so there is a single built file per resource (no dual CJS/ESM
// variants). Each SDK resource class exposes async methods that return a thenable, so `kind: 'Auto'`
// resolves to `wrapPromise`; the `.stream` methods resolve to an async-iterable `EventStream`.
-const MODULE = { name: '@mistralai/mistralai', versionRange: '>=2.0.0 <3' } as const;
+const MODULE = { name: '@mistralai/mistralai', versionRange: '>=2.0.0 <4' } as const;
const CHAT_FILE = { ...MODULE, filePath: 'esm/sdk/chat.js' } as const;
const AGENTS_FILE = { ...MODULE, filePath: 'esm/sdk/agents.js' } as const;
diff --git a/packages/server-utils/src/orchestrion/config/nestjs.ts b/packages/server-utils/src/orchestrion/config/nestjs.ts
index 344b35913ede..b08971e01726 100644
--- a/packages/server-utils/src/orchestrion/config/nestjs.ts
+++ b/packages/server-utils/src/orchestrion/config/nestjs.ts
@@ -93,6 +93,13 @@ export const nestjsConfig = [
module: { name: '@nestjs/schedule', versionRange: '>=2.0.0', filePath: 'dist/decorators/timeout.decorator.js' },
functionQuery: { functionName: 'Timeout', kind: 'Sync' },
},
+ {
+ // The function `ScheduleExplorer` returns here is what `@nestjs/schedule` passes to `CronJob` as `onTick`.
+ // From 6.0.0 it uses `cron` 4.3+, whose jobs have names.
+ channelName: 'scheduleExplorerWrap',
+ module: { name: '@nestjs/schedule', versionRange: '>=6.0.0', filePath: 'dist/schedule.explorer.js' },
+ functionQuery: { className: 'ScheduleExplorer', methodName: 'wrapFunctionInTryCatchBlocks', kind: 'Sync' },
+ },
{
// @nestjs/event-emitter @OnEvent:
// `const OnEvent = (event, options) => {
@@ -138,6 +145,7 @@ export const nestjsChannels = {
NESTJS_SCHEDULE_CRON: 'orchestrion:@nestjs/schedule:cronDecorator',
NESTJS_SCHEDULE_INTERVAL: 'orchestrion:@nestjs/schedule:intervalDecorator',
NESTJS_SCHEDULE_TIMEOUT: 'orchestrion:@nestjs/schedule:timeoutDecorator',
+ NESTJS_SCHEDULE_EXPLORER_WRAP: 'orchestrion:@nestjs/schedule:scheduleExplorerWrap',
NESTJS_ONEVENT: 'orchestrion:@nestjs/event-emitter:onEventDecorator',
NESTJS_PROCESSOR: 'orchestrion:@nestjs/bullmq:processorDecorator',
} as const;
diff --git a/packages/server-utils/src/orchestrion/config/node-cron.ts b/packages/server-utils/src/orchestrion/config/node-cron.ts
new file mode 100644
index 000000000000..f0688dcc483d
--- /dev/null
+++ b/packages/server-utils/src/orchestrion/config/node-cron.ts
@@ -0,0 +1,52 @@
+import type { InstrumentationConfig } from '../apmTypes';
+import { getModuleNames } from './module-names';
+
+const createTask = { functionName: 'createTask', kind: 'Sync' } as const;
+
+// `schedule()` calls `createTask(expression, func, options)`, so wrapping `createTask` covers both.
+// Before 4.6, node-cron reads weekday ranges like `sat-sun` differently, so those versions get their own channel.
+export const nodeCronConfig = [
+ {
+ channelName: 'createTask',
+ module: { name: 'node-cron', versionRange: '>=4.6.0 <5', filePath: 'dist/node-cron.cjs' },
+ functionQuery: createTask,
+ },
+ {
+ channelName: 'createTask',
+ module: { name: 'node-cron', versionRange: '>=4.6.0 <5', filePath: 'dist/node-cron.js' },
+ functionQuery: createTask,
+ },
+ {
+ channelName: 'v4_createTask',
+ module: { name: 'node-cron', versionRange: '>=4.3.0 <4.6.0', filePath: 'dist/node-cron.cjs' },
+ functionQuery: createTask,
+ },
+ {
+ channelName: 'v4_createTask',
+ module: { name: 'node-cron', versionRange: '>=4.0.0 <4.6.0', filePath: 'dist/node-cron.js' },
+ functionQuery: createTask,
+ },
+ {
+ channelName: 'v4_createTask',
+ module: { name: 'node-cron', versionRange: '>=4.0.0 <4.3.0', filePath: 'dist/cjs/node-cron.js' },
+ functionQuery: createTask,
+ },
+ {
+ channelName: 'v4_createTask',
+ module: { name: 'node-cron', versionRange: '>=4.0.0 <4.3.0', filePath: 'dist/esm/node-cron.js' },
+ functionQuery: createTask,
+ },
+ {
+ channelName: 'v3_createTask',
+ module: { name: 'node-cron', versionRange: '>=3.0.0 <4', filePath: 'src/node-cron.js' },
+ functionQuery: createTask,
+ },
+] satisfies InstrumentationConfig[];
+
+export const nodeCronModuleNames = getModuleNames(nodeCronConfig);
+
+export const nodeCronChannels = {
+ NODE_CRON_CREATE_TASK: 'orchestrion:node-cron:createTask',
+ NODE_CRON_V4_CREATE_TASK: 'orchestrion:node-cron:v4_createTask',
+ NODE_CRON_V3_CREATE_TASK: 'orchestrion:node-cron:v3_createTask',
+} as const;
diff --git a/packages/server-utils/src/orchestrion/config/node-schedule.ts b/packages/server-utils/src/orchestrion/config/node-schedule.ts
new file mode 100644
index 000000000000..acb3733a1b69
--- /dev/null
+++ b/packages/server-utils/src/orchestrion/config/node-schedule.ts
@@ -0,0 +1,29 @@
+import type { InstrumentationConfig } from '../apmTypes';
+import { getModuleNames } from './module-names';
+
+// `scheduleJob`, `rescheduleJob` and `job.reschedule` all call `job.schedule(spec)`. `Job` moved from
+// `lib/schedule.js` to `lib/Job.js` in 2.0.0.
+const schedule = {
+ channelName: 'schedule',
+ astQuery:
+ "AssignmentExpression[left.object.property.name='prototype'][left.property.name='schedule'] > FunctionExpression",
+ // `methodName` gives subscribers `self` before the call, not just after. `astQuery` still picks the function.
+ functionQuery: { methodName: 'schedule', kind: 'Sync' },
+} as const;
+
+export const nodeScheduleConfig = [
+ {
+ ...schedule,
+ module: { name: 'node-schedule', versionRange: '>=1.3.0 <2', filePath: 'lib/schedule.js' },
+ },
+ {
+ ...schedule,
+ module: { name: 'node-schedule', versionRange: '>=2.0.0 <3', filePath: 'lib/Job.js' },
+ },
+] satisfies InstrumentationConfig[];
+
+export const nodeScheduleModuleNames = getModuleNames(nodeScheduleConfig);
+
+export const nodeScheduleChannels = {
+ NODE_SCHEDULE_SCHEDULE: 'orchestrion:node-schedule:schedule',
+} as const;
diff --git a/packages/server-utils/src/orchestrion/config/postgres.ts b/packages/server-utils/src/orchestrion/config/postgres.ts
index b091bbde53ca..b58899b44b7d 100644
--- a/packages/server-utils/src/orchestrion/config/postgres.ts
+++ b/packages/server-utils/src/orchestrion/config/postgres.ts
@@ -1,8 +1,9 @@
import type { InstrumentationConfig } from '../apmTypes';
import { getModuleNames } from './module-names';
+import { registrationOnly } from './registration-only';
-// postgres.js (`postgres` npm package, v3.x). Named after the npm package;
+// postgres.js (`postgres` npm package). Named after the npm package;
// `postgres` doesn't collide with `pg.ts` (that file instruments `pg`/`pg-pool`).
//
// The ESM build lives under `src/*`, the CJS build under `cjs/src/*` (the
@@ -47,7 +48,13 @@ const postgresJsInstrumentationConfig = (dir: string): InstrumentationConfig[] =
},
];
-export const postgresJsConfig = ['src', 'cjs/src'].flatMap(postgresJsInstrumentationConfig);
+// Gated to `< 4`: from 4.0.0 postgres publishes `postgres:query` natively, which
+// `subscribePostgresJsDiagnosticChannels` instruments. The registration-only entry
+// lets bundler-only SDKs detect v4 and register the integration.
+export const postgresJsConfig = [
+ registrationOnly({ name: 'postgres', versionRange: '>=4.0.0', filePath: 'src/query.js' }),
+ ...['src', 'cjs/src'].flatMap(postgresJsInstrumentationConfig),
+];
export const postgresJsModuleNames = getModuleNames(postgresJsConfig);
diff --git a/packages/server-utils/src/tracing-channel.ts b/packages/server-utils/src/tracing-channel.ts
index 075057f47c31..859b80738af7 100644
--- a/packages/server-utils/src/tracing-channel.ts
+++ b/packages/server-utils/src/tracing-channel.ts
@@ -8,7 +8,6 @@ import {
SPAN_STATUS_ERROR,
getAsyncContextStrategy,
getMainCarrier,
- getActiveSpan,
spanIsIgnored,
getRootSpan,
withActiveSpan,
@@ -72,12 +71,6 @@ export interface TracingChannelLifeCycleOptions {
/** Ends the span: `end()` on success, `end(error)` on failure (which marks `data` as errored). Idempotent. */
end: (error?: unknown) => void;
}) => boolean;
-
- /**
- * Apply span/scope binding only if there is a parent span, similar to returning `undefined` in the `getSpan` callback.
- * If you need to perform some conditional checking on the parent span before deciding, do it in the `getSpan` callback.
- */
- requiresParentSpan?: boolean;
}
/** Returned by {@link bindTracingChannelToSpan}: the bound channel plus a teardown handle. */
@@ -120,7 +113,7 @@ export function bindTracingChannelToSpan(
getSpan: (data: TracingChannelPayloadWithSpan) => Span | undefined,
opts?: TracingChannelLifeCycleOptions