diff --git a/.github/workflows/auto-fix-issue.yml b/.github/workflows/auto-fix-issue.yml index 245ffc18b3f4..559f255e3e98 100644 --- a/.github/workflows/auto-fix-issue.yml +++ b/.github/workflows/auto-fix-issue.yml @@ -77,7 +77,7 @@ jobs: - name: Try to fix the issue with Claude id: triage - uses: anthropics/claude-code-action@a874e9ecd7bb36efdad65429c6b35815f5a08f10 # v1 + uses: anthropics/claude-code-action@8ce9314fa9a404564fa7e954cd84f25bcba2b829 # 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 d6c654de029a..8a2e73d10327 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -51,7 +51,7 @@ jobs: node-version-file: 'package.json' - name: Prepare release - uses: getsentry/craft@cd1e8294061fd970b40d98b77aaa109cb1e00e78 # v2.30.1 + uses: getsentry/craft@25028d0646040cc0a669ae3314cbf884f4c4347a # v2.31.2 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 13bfa91520fc..f680c6c3c630 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -886,7 +886,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} # The fixtures install the locally-packed `@sentry/bundler-plugins` tarball with pnpm. - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 9.15.9 - name: Set up Node @@ -1046,7 +1046,7 @@ jobs: uses: actions/checkout@v7 with: ref: ${{ env.HEAD_COMMIT }} - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: ${{ matrix.test-application == 'nuxt-5' && '11.10.0' || '9.15.9' }} - name: Set up Node @@ -1186,7 +1186,7 @@ jobs: uses: actions/checkout@v7 with: ref: ${{ env.HEAD_COMMIT }} - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: ${{ matrix.test-application == 'nuxt-5' && '11.10.0' || '9.15.9' }} - name: Set up Node diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index a24354e3497b..fd9c8d6ce524 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -117,7 +117,7 @@ jobs: uses: actions/checkout@v7 with: ref: ${{ env.HEAD_COMMIT }} - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 9.15.9 - name: Set up Node diff --git a/.github/workflows/cleanup-e2e-workers.yml b/.github/workflows/cleanup-e2e-workers.yml index f87a387d281b..69aae225e408 100644 --- a/.github/workflows/cleanup-e2e-workers.yml +++ b/.github/workflows/cleanup-e2e-workers.yml @@ -19,6 +19,7 @@ jobs: # Name prefix of every E2E app that deploys a real worker, see the app's global-setup.ts worker-prefix: - e2e-send-to-sentry + - e2e-flue-send-to-sentry steps: - name: Set up Node uses: actions/setup-node@v7 diff --git a/.github/workflows/dependabot-auto-triage.yml b/.github/workflows/dependabot-auto-triage.yml index 2d10df4fb2be..3a94bd400aa9 100644 --- a/.github/workflows/dependabot-auto-triage.yml +++ b/.github/workflows/dependabot-auto-triage.yml @@ -160,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@a874e9ecd7bb36efdad65429c6b35815f5a08f10 # v1 + uses: anthropics/claude-code-action@8ce9314fa9a404564fa7e954cd84f25bcba2b829 # v1 env: ANTHROPIC_BASE_URL: https://openrouter.ai/api with: @@ -249,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@a874e9ecd7bb36efdad65429c6b35815f5a08f10 # v1 + uses: anthropics/claude-code-action@8ce9314fa9a404564fa7e954cd84f25bcba2b829 # v1 env: ANTHROPIC_BASE_URL: https://openrouter.ai/api with: diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index 6df0c712f2bd..6928d5d4073e 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -19,7 +19,7 @@ jobs: VITE_E2E_TEST_DSN: 'https://username@domain/123' steps: - uses: actions/checkout@v7 - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 9.15.9 - uses: actions/setup-node@v7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0c3d5d08c93c..7840b35f677a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: with: node-version-file: 'package.json' - name: Prepare release - uses: getsentry/craft@cd1e8294061fd970b40d98b77aaa109cb1e00e78 # v2.30.1 + uses: getsentry/craft@25028d0646040cc0a669ae3314cbf884f4c4347a # v2.31.2 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: diff --git a/.github/workflows/track-framework-updates.yml b/.github/workflows/track-framework-updates.yml index 723d1661c0d2..0a74bad0b923 100644 --- a/.github/workflows/track-framework-updates.yml +++ b/.github/workflows/track-framework-updates.yml @@ -43,7 +43,7 @@ jobs: - name: Run Claude digest id: digest - uses: anthropics/claude-code-action@a874e9ecd7bb36efdad65429c6b35815f5a08f10 # v1 + uses: anthropics/claude-code-action@8ce9314fa9a404564fa7e954cd84f25bcba2b829 # v1 env: ANTHROPIC_BASE_URL: https://openrouter.ai/api with: diff --git a/.github/workflows/triage-issue.yml b/.github/workflows/triage-issue.yml index 9994c56b9900..78ec8b6586e5 100644 --- a/.github/workflows/triage-issue.yml +++ b/.github/workflows/triage-issue.yml @@ -54,7 +54,7 @@ jobs: - name: Run Claude triage id: triage - uses: anthropics/claude-code-action@a874e9ecd7bb36efdad65429c6b35815f5a08f10 # v1 + uses: anthropics/claude-code-action@8ce9314fa9a404564fa7e954cd84f25bcba2b829 # 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 7ccbd41bf16d..9e29641264dc 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -10,7 +10,7 @@ jobs: permissions: pull-requests: write steps: - - uses: getsentry/github-workflows/validate-pr@4013fc6e1aeb1be1f9d3b4d232624f0ec1afa613 + - uses: getsentry/github-workflows/validate-pr@36c729264d2edc29ebae61950c50e1e9f043ad7e with: app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }} private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }} diff --git a/.size-limit.js b/.size-limit.js index 80ad8c9f5f4f..220fa9c349e8 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -89,7 +89,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '96 KB', + limit: '97 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -97,7 +97,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '85 KB', + limit: '86 KB', disablePlugins: ['@size-limit/esbuild'], modifyWebpackConfig: function (config) { const webpack = require('webpack'); @@ -137,7 +137,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'feedbackIntegration'), gzip: true, - limit: '52 KB', + limit: '53 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -187,7 +187,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '36 KB', + limit: '37 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -205,7 +205,7 @@ module.exports = [ path: 'packages/vue/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '42 KB', + limit: '43 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -213,7 +213,7 @@ module.exports = [ path: 'packages/vue/build/esm/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '59 KB', + limit: '60 KB', disablePlugins: ['@size-limit/esbuild'], }, // Svelte SDK (ESM) @@ -225,12 +225,25 @@ module.exports = [ limit: '35 KB', disablePlugins: ['@size-limit/esbuild'], }, + // Remix 3 browser SDK (ESM) + { + // Every export, not a named import: Remix 3 has no bundler, so an app ships every byte of this + // file. The budget is what keeps `src/v3/index.client.ts` a named list. One added + // `export * from '@sentry/browser'` measures 134 KB here, and more on the wire, because a real app + // has no bundler to shake it. + name: '@sentry/remix (Remix 3 client bundle)', + path: 'packages/remix/build/esm/v3/client-bundle.js', + import: '*', + gzip: true, + limit: '61 KB', + disablePlugins: ['@size-limit/esbuild'], + }, // Browser CDN bundles { name: 'CDN Bundle', path: createCDNPath('bundle.min.js'), gzip: true, - limit: '36 KB', + limit: '37 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -258,7 +271,7 @@ module.exports = [ name: 'CDN Bundle (incl. Replay, Logs, Metrics)', path: createCDNPath('bundle.replay.logs.metrics.min.js'), gzip: true, - limit: '79 KB', + limit: '80 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -295,7 +308,7 @@ module.exports = [ path: createCDNPath('bundle.min.js'), gzip: false, brotli: false, - limit: '97 KB', + limit: '98 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -303,7 +316,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '159 KB', + limit: '160 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -311,7 +324,7 @@ module.exports = [ path: createCDNPath('bundle.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '103 KB', + limit: '104 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -319,7 +332,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '165 KB', + limit: '166 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -327,7 +340,7 @@ module.exports = [ path: createCDNPath('bundle.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '233 KB', + limit: '234 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -335,7 +348,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.min.js'), gzip: false, brotli: false, - limit: '279 KB', + limit: '280 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -343,7 +356,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '285 KB', + limit: '286 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -369,7 +382,7 @@ module.exports = [ import: createImport('init'), ignore: ['next/router', 'next/constants'], gzip: true, - limit: '61 KB', + limit: '62 KB', disablePlugins: ['@size-limit/esbuild'], }, // SvelteKit SDK (ESM) @@ -388,7 +401,7 @@ module.exports = [ path: 'packages/core/build/esm/server.js', import: '*', gzip: true, - limit: '45 KB', + limit: '46 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -406,7 +419,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '145 KB', + limit: '150 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -414,7 +427,7 @@ module.exports = [ path: ['packages/server-runtime-injection/build/esm/hook.js', 'packages/node/build/import-hook.mjs'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '88 KB', + limit: '89 KB', disablePlugins: ['@size-limit/esbuild'], modifyWebpackConfig: function (config) { // Both packages declare `sideEffects: false`, which lets webpack @@ -430,7 +443,7 @@ module.exports = [ path: 'packages/node/build/esm/index.js', import: createImport('initWithoutDefaultIntegrations', 'getDefaultIntegrationsWithoutPerformance'), gzip: true, - limit: '96 KB', + limit: '99 KB', disablePlugins: ['@size-limit/esbuild'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { @@ -452,7 +465,7 @@ module.exports = [ path: 'packages/node/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '123 KB', + limit: '128 KB', disablePlugins: ['@size-limit/esbuild'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { @@ -473,7 +486,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '104 KB', + limit: '107 KB', disablePlugins: ['@size-limit/esbuild'], }, // Cloudflare SDK (ESM) - compressed, minified to match `wrangler deploy --dry-run --minify` output @@ -484,7 +497,7 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, - limit: '208 KiB', + limit: '209 KiB', disablePlugins: ['@size-limit/webpack'], webpack: false, modifyEsbuildConfig: function (config) { @@ -504,7 +517,7 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, - limit: '507 KiB', + limit: '512 KiB', disablePlugins: ['@size-limit/webpack'], webpack: false, modifyEsbuildConfig: function (config) { diff --git a/CHANGELOG.md b/CHANGELOG.md index ec252e25122e..8f710752af09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,87 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 11.3.0 + +### Important Changes + +- **Remix 3 support (alpha, may break in minor releases)** + + - feat(remix): Add Remix 3 browser SDK with a bundled client entry ([#24802](https://github.com/getsentry/sentry-javascript/pull/24802)) + - feat(remix): Add source map upload for Remix 3 ([#24943](https://github.com/getsentry/sentry-javascript/pull/24943)) + - feat(remix): Capture Remix 3 component render errors ([#24873](https://github.com/getsentry/sentry-javascript/pull/24873)) + - feat(remix): Complete Remix 3 server error handling ([#24878](https://github.com/getsentry/sentry-javascript/pull/24878)) + - feat(remix): Inject debug IDs through the Remix 3 asset server ([#24761](https://github.com/getsentry/sentry-javascript/pull/24761)) + - feat(remix): Run the orchestrion transform on Remix 3 browser modules ([#24894](https://github.com/getsentry/sentry-javascript/pull/24894)) + +- **feat(nextjs): Add `cache_origin` span links to `use cache` hit spans ([#24821](https://github.com/getsentry/sentry-javascript/pull/24821))** + + A `cache.get` span for a `"use cache"` hit now carries a span link (`sentry.link.type: 'cache_origin'`) to the `cache.put` span of the request that filled the cache entry, connecting the trace that reads a cached value to the trace that produced it. + +- **fix(cloudflare): Don't treat DO constructor work as incoming RPC calls ([#24829](https://github.com/getsentry/sentry-javascript/pull/24829))** + + Durable Object constructors now run in their own isolation scope. When work that the constructor starts, for example a `blockConcurrencyWhile` callback, calls a method of the instance, that call is no longer treated as an incoming RPC call, so an error the instance catches itself is no longer reported as unhandled. As a result, `Sentry.setTag()`, `setUser()` and `setContext()` in a constructor now apply only to that constructor work. They no longer reach later `fetch`, RPC or `alarm` invocations, because the scope they used to write to is shared by every Durable Object and handler in the isolate. Use `initialScope` for static data, and set per-instance data in each handler. + +- **fix(effect)!: Change peerDependency to v4 and fix currentSpan ([#24940](https://github.com/getsentry/sentry-javascript/pull/24940))** + + `@sentry/effect` now requires a stable Effect v4 release. Effect v4 beta and release candidate versions are no longer supported. `@sentry/effect` is in alpha, so this ships in a minor release. + +### Other Changes + +- feat(astro): Register astro route provider ([#23792](https://github.com/getsentry/sentry-javascript/pull/23792)) +- feat(cloudflare): Trace TypeSafe Jev calls in Workers AI as evaluate spans ([#24833](https://github.com/getsentry/sentry-javascript/pull/24833)) +- feat(core): Add route provider API for parameterized route resolution ([#23551](https://github.com/getsentry/sentry-javascript/pull/23551)) +- feat(deps): bump devalue from 5.9.2 to 5.9.4 ([#24964](https://github.com/getsentry/sentry-javascript/pull/24964)) +- feat(effect): Add opt-in for external span parents and isolate root spans ([#23900](https://github.com/getsentry/sentry-javascript/pull/23900)) +- feat(nextjs): Register nextjs route provider ([#23552](https://github.com/getsentry/sentry-javascript/pull/23552)) +- feat(nuxt): Register nuxt route provider ([#23794](https://github.com/getsentry/sentry-javascript/pull/23794)) +- feat(remix): Register remix route provider ([#23791](https://github.com/getsentry/sentry-javascript/pull/23791)) +- feat(vue): Register Vue route provider ([#23793](https://github.com/getsentry/sentry-javascript/pull/23793)) +- fix(bundler-plugins): Keep debug IDs on Vite source maps after the preload rewrite ([#24920](https://github.com/getsentry/sentry-javascript/pull/24920)) +- fix(bundler-plugins): Stabilize debug IDs for Rolldown and Vite 8 builds ([#24919](https://github.com/getsentry/sentry-javascript/pull/24919)) +- fix(cloudflare): Skip binding instrumentation work when spans are not sent ([#24655](https://github.com/getsentry/sentry-javascript/pull/24655)) +- fix(core, node, bun, deno): Align server span client address with event IP ([#24767](https://github.com/getsentry/sentry-javascript/pull/24767)) +- fix(hono): Return the existing client on repeated init in Bun and Deno ([#24520](https://github.com/getsentry/sentry-javascript/pull/24520)) +- fix(nitro): Capture errors only through the Nitro error hook ([#24952](https://github.com/getsentry/sentry-javascript/pull/24952)) +- fix(profiling-node): Send profile chunks with `client.sendEnvelope` ([#24896](https://github.com/getsentry/sentry-javascript/pull/24896)) +- fix(react-router): Avoid duplicating Vite config arrays ([#24930](https://github.com/getsentry/sentry-javascript/pull/24930)) +- fix(react-router): Resolve the Cloudflare entry in Workers and skip trace meta tags in prerendered pages ([#24866](https://github.com/getsentry/sentry-javascript/pull/24866)) +- fix(server-utils): Match the Anthropic stream helper header regardless of case ([#24855](https://github.com/getsentry/sentry-javascript/pull/24855)) +- fix(server-utils): Skip Fastify 4xx errors raised before the reply status is set ([#24924](https://github.com/getsentry/sentry-javascript/pull/24924)) +- fix(tanstackstart-react): Avoid duplicating Vite config arrays ([#24929](https://github.com/getsentry/sentry-javascript/pull/24929)) + +
+ Internal Changes + +- chore(deps-dev): bump @vercel/nft from 1.5.0 to 1.11.0 ([#24954](https://github.com/getsentry/sentry-javascript/pull/24954)) +- chore(deps): Bump @sentry/conventions to 0.25.0 ([#24958](https://github.com/getsentry/sentry-javascript/pull/24958)) +- chore(deps): dev dependency security fixes ([#24275](https://github.com/getsentry/sentry-javascript/pull/24275)) +- chore(size-limit): weekly auto-bump ([#24969](https://github.com/getsentry/sentry-javascript/pull/24969)) +- ci(deps): bump anthropics/claude-code-action from 1.0.210 to 1.0.236 ([#24950](https://github.com/getsentry/sentry-javascript/pull/24950)) +- ci(deps): bump getsentry/craft from 2.30.1 to 2.31.2 ([#24951](https://github.com/getsentry/sentry-javascript/pull/24951)) +- ci(deps): bump getsentry/github-workflows/validate-pr from 4013fc6e1aeb1be1f9d3b4d232624f0ec1afa613 to 36c729264d2edc29ebae61950c50e1e9f043ad7e ([#24949](https://github.com/getsentry/sentry-javascript/pull/24949)) +- ci(deps): bump pnpm/action-setup from 6.0.10 to 6.1.0 ([#24948](https://github.com/getsentry/sentry-javascript/pull/24948)) +- license: Add cli FSL notice ([#24956](https://github.com/getsentry/sentry-javascript/pull/24956)) +- ref(core): Use cache attribute constants from conventions ([#24973](https://github.com/getsentry/sentry-javascript/pull/24973)) +- ref(core): Use conversation ID constant from conventions ([#24966](https://github.com/getsentry/sentry-javascript/pull/24966)) +- ref(core): Use exclusive time constant from conventions ([#24971](https://github.com/getsentry/sentry-javascript/pull/24971)) +- ref(core): Use HTTP method constant from conventions ([#24977](https://github.com/getsentry/sentry-javascript/pull/24977)) +- ref(core): Use idle span finish reason constant from conventions ([#24970](https://github.com/getsentry/sentry-javascript/pull/24970)) +- ref(core): Use profile ID constant from conventions ([#24976](https://github.com/getsentry/sentry-javascript/pull/24976)) +- ref(core): Use SDK metadata constants from conventions ([#24978](https://github.com/getsentry/sentry-javascript/pull/24978)) +- ref(core): Use status message constant from conventions ([#24967](https://github.com/getsentry/sentry-javascript/pull/24967)) +- ref(core): Use user attribute constants from conventions ([#24974](https://github.com/getsentry/sentry-javascript/pull/24974)) +- ref(server-utils): Clarify API promise helper naming and references ([#24928](https://github.com/getsentry/sentry-javascript/pull/24928)) +- test(cloudflare): Add Flue E2E test that deploys a real Worker and sends to Sentry ([#24816](https://github.com/getsentry/sentry-javascript/pull/24816)) +- test(cloudflare): Match the expected error in the WebSocket e2e tests ([#24923](https://github.com/getsentry/sentry-javascript/pull/24923)) +- test(deno): Port integration tests to span streaming ([#24870](https://github.com/getsentry/sentry-javascript/pull/24870)) +- test(e2e): Add node-anthropic-send-to-sentry test app ([#24856](https://github.com/getsentry/sentry-javascript/pull/24856)) +- test(e2e): Avoid rate limits when polling Sentry in send-to-sentry tests ([#24957](https://github.com/getsentry/sentry-javascript/pull/24957)) + +
+ +Work in this release was contributed by @Shubham-Padkonde and @tobias-schnabel. Thank you for your contributions! + ## 11.2.0 ### Important Changes diff --git a/LICENSE b/LICENSE index 84d8d8c065fc..32c62f9869e3 100644 --- a/LICENSE +++ b/LICENSE @@ -19,3 +19,13 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Third-Party Dependency Notice + +The MIT License above applies to the code in this repository. Some packages +published from this repository have the Sentry CLI (the `sentry` package on npm, +https://github.com/getsentry/cli) as a direct or transitive dependency. The +Sentry CLI is not part of this repository and is not covered by the MIT License +above. It is licensed under the Functional Source License, Version 1.1, Apache +2.0 Future License (FSL-1.1-Apache-2.0): +https://github.com/getsentry/cli/blob/main/LICENSE.md diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 30ef30f621b4..b89832568047 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -63,7 +63,7 @@ "@sentry/browser": "11.2.0", "@sentry/replay": "11.2.0", "@sentry/opentelemetry": "11.2.0", - "@sentry/conventions": "0.24.0", + "@sentry/conventions": "0.25.0", "@supabase/supabase-js": "2.109.0", "axios": "1.20.0", "babel-loader": "^10.1.1", diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts index 62d8bd9c6366..289a5716746f 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts @@ -1,12 +1,9 @@ import { expect } from '@playwright/test'; import { SDK_VERSION, - SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, - SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE, } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; import { shouldSkipTracingTest } from '../../../../utils/helpers'; @@ -19,6 +16,9 @@ import { SENTRY_SDK_VERSION, SENTRY_TRACE_LIFECYCLE, USER_AGENT_ORIGINAL, + SENTRY_STATUS_MESSAGE, + SENTRY_ENVIRONMENT, + SENTRY_SDK_INTEGRATIONS, } from '@sentry/conventions/attributes'; sentryTest( @@ -101,7 +101,7 @@ sentryTest( type: 'string', value: 'test-span', }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production', }, @@ -146,7 +146,7 @@ sentryTest( type: 'string', value: 'test-span', }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production', }, @@ -191,11 +191,11 @@ sentryTest( type: 'string', value: 'test-span', }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production', }, - [SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]: { + [SENTRY_STATUS_MESSAGE]: { type: 'string', value: 'Connection Refused', }, @@ -260,7 +260,7 @@ sentryTest( type: 'string', value: SDK_VERSION, }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: { + [SENTRY_SDK_INTEGRATIONS]: { type: 'array', value: expect.arrayContaining(['SpanStreaming']), }, @@ -276,7 +276,7 @@ sentryTest( type: 'string', value: 'custom', }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production', }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/test.ts index 70b6f2c66fd4..3ef7ddefca6d 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/test.ts @@ -1,5 +1,6 @@ +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { expect } from '@playwright/test'; -import { extractTraceparentData, parseBaggageHeader, SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { extractTraceparentData, parseBaggageHeader } from '@sentry/core'; import { sentryTest } from '../../../../../../utils/fixtures'; import { shouldSkipTracingTest, waitForTracingHeadersOnUrl } from '../../../../../../utils/helpers'; import { getSpanOp, waitForStreamedSpanEnvelope } from '../../../../../../utils/spanUtils'; @@ -66,7 +67,7 @@ sentryTest.describe('When `consistentTraceSampling` is `true`', () => { span_id: customTraceSpan.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + [SENTRY_LINK_TYPE]: { type: 'string', value: 'previous_trace', }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/test.ts index 32d62350d6b4..37e3b204fe88 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/test.ts @@ -1,7 +1,7 @@ +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { expect } from '@playwright/test'; import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE } from '@sentry/browser'; import type { ClientReport } from '@sentry/core'; -import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; import { sentryTest } from '../../../../../../utils/fixtures'; import { envelopeRequestParser, @@ -103,7 +103,7 @@ sentryTest.describe('When `consistentTraceSampling` is `true`', () => { expect(customTrace2Span.links).toEqual([ { attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + [SENTRY_LINK_TYPE]: { type: 'string', value: 'previous_trace', }, @@ -136,7 +136,7 @@ sentryTest.describe('When `consistentTraceSampling` is `true`', () => { expect(navigationSpan.links).toEqual([ { attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + [SENTRY_LINK_TYPE]: { type: 'string', value: 'previous_trace', }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/test.ts index 571f4a6d8b5e..7bb7a5546910 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../../utils/fixtures'; import { shouldSkipTracingTest } from '../../../../../utils/helpers'; import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; @@ -27,7 +27,7 @@ sentryTest('manually started custom traces are linked correctly in the chain', a span_id: pageloadSpan.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + [SENTRY_LINK_TYPE]: { type: 'string', value: 'previous_trace', }, @@ -52,7 +52,7 @@ sentryTest('manually started custom traces are linked correctly in the chain', a span_id: customTraceSpan.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + [SENTRY_LINK_TYPE]: { type: 'string', value: 'previous_trace', }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/default/test.ts index c8d7af8246be..5fb19844a3ce 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/default/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/default/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../../utils/fixtures'; import { shouldSkipTracingTest } from '../../../../../utils/helpers'; import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; @@ -33,7 +33,7 @@ sentryTest("navigation spans link back to previous trace's root span", async ({ span_id: pageloadSpan.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + [SENTRY_LINK_TYPE]: { type: 'string', value: 'previous_trace', }, @@ -52,7 +52,7 @@ sentryTest("navigation spans link back to previous trace's root span", async ({ span_id: navigation1Span.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + [SENTRY_LINK_TYPE]: { type: 'string', value: 'previous_trace', }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/test.ts index 1e5666e116e8..bfc974fc953a 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../../utils/fixtures'; import { shouldSkipTracingTest } from '../../../../../utils/helpers'; import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; @@ -52,7 +52,7 @@ sentryTest( span_id: pageloadSpan.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + [SENTRY_LINK_TYPE]: { type: 'string', value: 'previous_trace', }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/test.ts index 141e5a9505dc..732547954091 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../../utils/fixtures'; import { shouldSkipTracingTest } from '../../../../../utils/helpers'; import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; @@ -37,7 +37,7 @@ sentryTest( span_id: pageloadSpan.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + [SENTRY_LINK_TYPE]: { type: 'string', value: 'previous_trace', }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/test.ts index 8b9b3d044dce..4bc05cb95737 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../../utils/fixtures'; import { shouldSkipTracingTest } from '../../../../../utils/helpers'; import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; @@ -26,7 +26,7 @@ sentryTest('includes a span link to a previously negatively sampled span', async span_id: expect.stringMatching(/[a-f\d]{16}/), sampled: false, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + [SENTRY_LINK_TYPE]: { type: 'string', value: 'previous_trace', }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/test.ts index 5ed1c8021d4a..81d1d31e10e3 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../../utils/fixtures'; import { shouldSkipTracingTest } from '../../../../../utils/helpers'; import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; @@ -30,7 +30,7 @@ sentryTest('adds link between hard page reloads when opting into sessionStorage' span_id: pageload1Span.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + [SENTRY_LINK_TYPE]: { type: 'string', value: 'previous_trace', }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/test.ts index 6787b23e6db8..4d13371bc693 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/test.ts @@ -1,10 +1,6 @@ +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { expect } from '@playwright/test'; -import { - extractTraceparentData, - parseBaggageHeader, - SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE, -} from '@sentry/core'; +import { extractTraceparentData, parseBaggageHeader, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE } from '@sentry/core'; import { sentryTest } from '../../../../../../utils/fixtures'; import { eventAndTraceHeaderRequestParser, @@ -76,7 +72,7 @@ sentryTest.describe('When `consistentTraceSampling` is `true`', () => { span_id: customTraceContext?.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }, ]); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/custom-trace/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/custom-trace/test.ts index b8d4f43cf03b..c4637dc25018 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/custom-trace/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/custom-trace/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../../utils/helpers'; @@ -31,7 +31,7 @@ sentryTest('manually started custom traces are linked correctly in the chain', a span_id: pageloadTraceContext?.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }, ]); @@ -54,7 +54,7 @@ sentryTest('manually started custom traces are linked correctly in the chain', a span_id: customTrace1Context?.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }, ]); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/default/test.ts index 96302d731e5e..7399c2bb26c3 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/default/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/default/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../../utils/helpers'; @@ -43,7 +43,7 @@ sentryTest("navigation spans link back to previous trace's root span", async ({ span_id: pageloadTraceContext?.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }, ]); @@ -58,7 +58,7 @@ sentryTest("navigation spans link back to previous trace's root span", async ({ span_id: navigation1TraceContext?.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }, ]); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/interaction-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/interaction-spans/test.ts index f8d01ab4a9d7..24a1e2c59072 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/interaction-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/interaction-spans/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../../utils/helpers'; @@ -61,7 +61,7 @@ sentryTest( span_id: pageloadTraceContext?.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }, ]); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/meta/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/meta/test.ts index 123f44e8db85..a232a5a0f6e7 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/meta/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/meta/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../../utils/helpers'; @@ -44,7 +44,7 @@ sentryTest( span_id: pageloadTraceContext?.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }, ]); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/negatively-sampled/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/negatively-sampled/test.ts index b84aefb8887d..894fe0d5929a 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/negatively-sampled/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/negatively-sampled/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../../utils/helpers'; @@ -28,7 +28,7 @@ sentryTest('includes a span link to a previously negatively sampled span', async span_id: expect.stringMatching(/[a-f\d]{16}/), sampled: false, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }, ]); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/session-storage/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/session-storage/test.ts index 19f34eef73f3..f7e0d288de5b 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/session-storage/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/session-storage/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../../utils/helpers'; @@ -31,7 +31,7 @@ sentryTest('adds link between hard page reloads when opting into sessionStorage' trace_id: pageload1TraceContext?.trace_id, span_id: pageload1TraceContext?.span_id, sampled: true, - attributes: { [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace' }, + attributes: { [SENTRY_LINK_TYPE]: 'previous_trace' }, }, ]); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts index b99e676afaae..5d298cb1e9cd 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts @@ -1,11 +1,9 @@ import { expect } from '@playwright/test'; import { SDK_VERSION, - SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, } from '@sentry/core'; import { SENTRY_SEGMENT_NAME_SOURCE, @@ -13,6 +11,8 @@ import { URL_FULL, URL_PATH, USER_AGENT_ORIGINAL, + SENTRY_ENVIRONMENT, + SENTRY_SDK_INTEGRATIONS, } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../utils/fixtures'; import { shouldSkipTracingTest } from '../../../../utils/helpers'; @@ -148,7 +148,7 @@ sentryTest('starts a streamed navigation span on page navigation', async ({ brow type: 'string', value: SDK_VERSION, }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: { + [SENTRY_SDK_INTEGRATIONS]: { type: 'array', value: expect.arrayContaining(['BrowserTracing', 'SpanStreaming']), }, @@ -164,7 +164,7 @@ sentryTest('starts a streamed navigation span on page navigation', async ({ brow type: 'string', value: 'url', }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production', }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts index 680b868b4659..1fdaaadce2bd 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts @@ -1,11 +1,9 @@ import { expect } from '@playwright/test'; import { SDK_VERSION, - SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, } from '@sentry/core'; import { BROWSER_NAVIGATION_TYPE, @@ -18,6 +16,8 @@ import { URL_FULL, URL_PATH, USER_AGENT_ORIGINAL, + SENTRY_ENVIRONMENT, + SENTRY_SDK_INTEGRATIONS, } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../utils/fixtures'; import { shouldSkipTracingTest } from '../../../../utils/helpers'; @@ -156,7 +156,7 @@ sentryTest( type: 'string', value: SDK_VERSION, }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: { + [SENTRY_SDK_INTEGRATIONS]: { type: 'array', value: expect.arrayContaining(['BrowserTracing', 'SpanStreaming']), }, @@ -172,7 +172,7 @@ sentryTest( type: 'string', value: 'url', }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production', }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts index 706166ef0dc4..3d3d4e69069c 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts @@ -1,11 +1,10 @@ -import { SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; +import { SENTRY_IDLE_SPAN_FINISH_REASON, SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; import { expect } from '@playwright/test'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, } from '@sentry/browser'; -import { SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON } from '@sentry/core'; import { sentryTest } from '../../../../../utils/fixtures'; import { shouldSkipTracingTest } from '../../../../../utils/helpers'; import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; @@ -30,7 +29,7 @@ sentryTest( [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: expect.objectContaining({ value: 1 }), [SENTRY_SEGMENT_NAME_SOURCE]: { type: 'string', value: 'url' }, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'pageload' }, - [SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: { type: 'string', value: 'reportPageLoaded' }, + [SENTRY_IDLE_SPAN_FINISH_REASON]: { type: 'string', value: 'reportPageLoaded' }, }); // We wait for 2.5 seconds before calling Sentry.reportPageLoaded(). The span starts at navigation start, diff --git a/dev-packages/browser-integration-tests/suites/tracing/interactions/spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/interactions/spans/test.ts index 0f6c7f4d772c..2a6123aa2aa9 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/interactions/spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/interactions/spans/test.ts @@ -1,13 +1,7 @@ import { expect } from '@playwright/test'; +import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import { - SDK_VERSION, - SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, - SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, -} from '@sentry/core'; -import { + SENTRY_IDLE_SPAN_FINISH_REASON, SENTRY_SEGMENT_ID, SENTRY_SEGMENT_NAME, SENTRY_SEGMENT_NAME_SOURCE, @@ -16,6 +10,8 @@ import { SENTRY_TRACE_LIFECYCLE, USER_AGENT_ORIGINAL, URL_PATH, + SENTRY_ENVIRONMENT, + SENTRY_SDK_INTEGRATIONS, } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../utils/fixtures'; import { shouldSkipTracingTest } from '../../../../utils/helpers'; @@ -76,7 +72,7 @@ sentryTest('captures streamed interaction span tree. @firefox', async ({ browser type: 'string', value: '/index.html', }, - [SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: { + [SENTRY_IDLE_SPAN_FINISH_REASON]: { type: 'string', value: 'idleTimeout', }, @@ -96,7 +92,7 @@ sentryTest('captures streamed interaction span tree. @firefox', async ({ browser type: 'string', value: SDK_VERSION, }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: { + [SENTRY_SDK_INTEGRATIONS]: { type: 'array', value: expect.arrayContaining(['BrowserTracing', 'SpanStreaming']), }, @@ -112,7 +108,7 @@ sentryTest('captures streamed interaction span tree. @firefox', async ({ browser type: 'string', value: 'custom', }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production', }, @@ -165,7 +161,7 @@ sentryTest('captures streamed interaction span tree. @firefox', async ({ browser type: 'string', value: 'Click', }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production', }, diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts index c9a89b180612..20124be5a264 100644 --- a/dev-packages/bun-integration-tests/node-suites/excludes.ts +++ b/dev-packages/bun-integration-tests/node-suites/excludes.ts @@ -83,6 +83,7 @@ export const NO_AUTO_INSTRUMENTATION = [ 'suites/pino/test.ts', 'suites/tracing/amqplib/test.ts', 'suites/tracing/anthropic/test.ts', + 'suites/tracing/anthropic/v0.129/test.ts', 'suites/tracing/apollo-graphql/**', 'suites/tracing/dataloader/test.ts', 'suites/tracing/fastify/test.ts', diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-disable-upload.config.d.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-disable-upload.config.d.ts new file mode 100644 index 000000000000..192b614bc29b --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-disable-upload.config.d.ts @@ -0,0 +1,2 @@ +import type { SentryRollupPluginOptions } from "@sentry/bundler-plugins/rollup"; +export declare const sentryConfig: SentryRollupPluginOptions; diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-disable-upload.config.js b/dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-disable-upload.config.js new file mode 100644 index 000000000000..91b4c2a30c83 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-disable-upload.config.js @@ -0,0 +1,6 @@ +export const sentryConfig = { + telemetry: false, + sourcemaps: { + disable: "disable-upload", + }, +}; diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/debugids-already-injected.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/debugids-already-injected.test.ts index c5a04f975b7c..950873a28a70 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/debugids-already-injected.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/debugids-already-injected.test.ts @@ -2,14 +2,24 @@ import { expect } from "vitest"; import { readAllFiles } from "../utils"; import { test } from "./utils"; +const normalizeDebugIds = (value: string): string => + value.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "00000000-0000-0000-0000-000000000000"); + test(import.meta.url, ({ runBundler, createTempDir }) => { const tempDir = createTempDir(); runBundler({ SENTRY_TEST_OVERRIDE_TEMP_DIR: tempDir }); - const files = readAllFiles(tempDir); + // Rolldown debug IDs are derived from the final chunk text, so they change with every Rolldown + // release. Zero them in file names and source maps to keep the snapshot stable. + const files = Object.fromEntries( + Object.entries(readAllFiles(tempDir, normalizeDebugIds)).map(([name, content]) => [ + normalizeDebugIds(name), + content, + ]) + ); expect(files).toMatchInlineSnapshot(` { - "b699d9c1-b033-4536-aa25-233c92609b54-0.js": "//#region src/basic.js + "00000000-0000-0000-0000-000000000000-0.js": "//#region src/basic.js (function() { try { var e = "undefined" != typeof window ? window : "undefined" != typeof global ? global : "undefined" != typeof globalThis ? globalThis : "undefined" != typeof self ? self : {}; @@ -23,7 +33,7 @@ test(import.meta.url, ({ runBundler, createTempDir }) => { //# debugId=00000000-0000-0000-0000-000000000000 //# sourceMappingURL=basic.js.map", - "b699d9c1-b033-4536-aa25-233c92609b54-0.js.map": "{"version":3,"file":"basic.js","names":[],"sources":["../../src/basic.js"],"sourcesContent":["// eslint-disable-next-line no-console\\nconsole.log(\\"hello world\\");\\n"],"mappings":";;;;;;;;;AACA,QAAQ,IAAI,cAAc","debugId":"b699d9c1-b033-4536-aa25-233c92609b54","debug_id":"b699d9c1-b033-4536-aa25-233c92609b54"}", + "00000000-0000-0000-0000-000000000000-0.js.map": "{"version":3,"file":"basic.js","names":[],"sources":["../../src/basic.js"],"sourcesContent":["// eslint-disable-next-line no-console\\nconsole.log(\\"hello world\\");\\n"],"mappings":";;;;;;;;;AACA,QAAQ,IAAI,cAAc","debugId":"00000000-0000-0000-0000-000000000000","debug_id":"00000000-0000-0000-0000-000000000000"}", } `); }); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/sourcemaps-disable-upload.config.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/sourcemaps-disable-upload.config.ts new file mode 100644 index 000000000000..aec427a21800 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/sourcemaps-disable-upload.config.ts @@ -0,0 +1,19 @@ +import { sentryVitePlugin } from "@sentry/bundler-plugins/vite"; +import { defineConfig } from "vite"; +import { sentryConfig } from "../configs/sourcemaps-disable-upload.config.js"; + +export default defineConfig({ + build: { + minify: false, + sourcemap: true, + rollupOptions: { + input: "src/dynamic-import.js", + output: { + dir: "out/sourcemaps-disable-upload", + entryFileNames: "[name].js", + chunkFileNames: "[name].js", + }, + }, + }, + plugins: [sentryVitePlugin(sentryConfig)], +}); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/sourcemaps-disable-upload.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/sourcemaps-disable-upload.test.ts new file mode 100644 index 000000000000..a3c65f8e7755 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/sourcemaps-disable-upload.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { test } from "./utils"; + +// Vite rewrites the source map of chunks with dynamic imports after the Sentry plugin stamps it. +// The debug ID must survive that for every chunk. Files are read raw here because +// `readOutputFiles` zeroes debug IDs, which would hide a mismatch between chunk and map. +test(import.meta.url, ({ runBundler, outDir }) => { + runBundler(); + + const entry = readFileSync(join(outDir, "dynamic-import.js"), "utf-8"); + expect(entry).toContain("__vitePreload"); + + for (const fileName of ["dynamic-import.js", "import.js"]) { + const chunk = readFileSync(join(outDir, fileName), "utf-8"); + const debugId = chunk.match(/sentry-dbid-([0-9a-f-]{36})/)?.[1]; + const map = JSON.parse(readFileSync(join(outDir, `${fileName}.map`), "utf-8")); + + expect(debugId).toBeDefined(); + expect(chunk).toContain(`//# debugId=${debugId}`); + expect(map.debug_id).toBe(debugId); + expect(map.debugId).toBe(debugId); + } +}); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/src/dynamic-import.js b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/src/dynamic-import.js new file mode 100644 index 000000000000..717d73bf4fe3 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/src/dynamic-import.js @@ -0,0 +1,5 @@ +export async function load() { + return import("./import.js"); +} + +load(); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/debugids-already-injected.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/debugids-already-injected.test.ts index 89189b0338dc..67c935d746f7 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/debugids-already-injected.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/debugids-already-injected.test.ts @@ -2,14 +2,24 @@ import { expect } from "vitest"; import { readAllFiles } from "../utils"; import { test } from "./utils"; +const normalizeDebugIds = (value: string): string => + value.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "00000000-0000-0000-0000-000000000000"); + test(import.meta.url, ({ runBundler, createTempDir }) => { const tempDir = createTempDir(); runBundler({ SENTRY_TEST_OVERRIDE_TEMP_DIR: tempDir }); - const files = readAllFiles(tempDir); + // Rolldown debug IDs are derived from the final chunk text, so they change with every Rolldown + // release. Zero them in file names and source maps to keep the snapshot stable. + const files = Object.fromEntries( + Object.entries(readAllFiles(tempDir, normalizeDebugIds)).map(([name, content]) => [ + normalizeDebugIds(name), + content, + ]) + ); expect(files).toMatchInlineSnapshot(` { - "b699d9c1-b033-4536-aa25-233c92609b54-0.js": "//#region src/basic.js + "00000000-0000-0000-0000-000000000000-0.js": "//#region src/basic.js (function() { try { var e = "undefined" != typeof window ? window : "undefined" != typeof global ? global : "undefined" != typeof globalThis ? globalThis : "undefined" != typeof self ? self : {}; @@ -23,7 +33,7 @@ test(import.meta.url, ({ runBundler, createTempDir }) => { //# debugId=00000000-0000-0000-0000-000000000000 //# sourceMappingURL=basic.js.map", - "b699d9c1-b033-4536-aa25-233c92609b54-0.js.map": "{"version":3,"file":"basic.js","names":[],"sources":["../../src/basic.js"],"sourcesContent":["// eslint-disable-next-line no-console\\nconsole.log(\\"hello world\\");\\n"],"mappings":";;;;;;;;;AACA,QAAQ,IAAI,aAAa","debugId":"b699d9c1-b033-4536-aa25-233c92609b54","debug_id":"b699d9c1-b033-4536-aa25-233c92609b54"}", + "00000000-0000-0000-0000-000000000000-0.js.map": "{"version":3,"file":"basic.js","names":[],"sources":["../../src/basic.js"],"sourcesContent":["// eslint-disable-next-line no-console\\nconsole.log(\\"hello world\\");\\n"],"mappings":";;;;;;;;;AACA,QAAQ,IAAI,aAAa","debugId":"00000000-0000-0000-0000-000000000000","debug_id":"00000000-0000-0000-0000-000000000000"}", } `); }); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/sourcemaps-disable-upload.config.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/sourcemaps-disable-upload.config.ts new file mode 100644 index 000000000000..aec427a21800 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/sourcemaps-disable-upload.config.ts @@ -0,0 +1,19 @@ +import { sentryVitePlugin } from "@sentry/bundler-plugins/vite"; +import { defineConfig } from "vite"; +import { sentryConfig } from "../configs/sourcemaps-disable-upload.config.js"; + +export default defineConfig({ + build: { + minify: false, + sourcemap: true, + rollupOptions: { + input: "src/dynamic-import.js", + output: { + dir: "out/sourcemaps-disable-upload", + entryFileNames: "[name].js", + chunkFileNames: "[name].js", + }, + }, + }, + plugins: [sentryVitePlugin(sentryConfig)], +}); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/sourcemaps-disable-upload.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/sourcemaps-disable-upload.test.ts new file mode 100644 index 000000000000..a3c65f8e7755 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/sourcemaps-disable-upload.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { test } from "./utils"; + +// Vite rewrites the source map of chunks with dynamic imports after the Sentry plugin stamps it. +// The debug ID must survive that for every chunk. Files are read raw here because +// `readOutputFiles` zeroes debug IDs, which would hide a mismatch between chunk and map. +test(import.meta.url, ({ runBundler, outDir }) => { + runBundler(); + + const entry = readFileSync(join(outDir, "dynamic-import.js"), "utf-8"); + expect(entry).toContain("__vitePreload"); + + for (const fileName of ["dynamic-import.js", "import.js"]) { + const chunk = readFileSync(join(outDir, fileName), "utf-8"); + const debugId = chunk.match(/sentry-dbid-([0-9a-f-]{36})/)?.[1]; + const map = JSON.parse(readFileSync(join(outDir, `${fileName}.map`), "utf-8")); + + expect(debugId).toBeDefined(); + expect(chunk).toContain(`//# debugId=${debugId}`); + expect(map.debug_id).toBe(debugId); + expect(map.debugId).toBe(debugId); + } +}); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/src/dynamic-import.js b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/src/dynamic-import.js new file mode 100644 index 000000000000..717d73bf4fe3 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/src/dynamic-import.js @@ -0,0 +1,5 @@ +export async function load() { + return import("./import.js"); +} + +load(); diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index 162e0a8136d9..2482616b6aa9 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -33,7 +33,7 @@ "@cloudflare/workers-types": "^4.20260426.0", "@cloudflare/workers-types-v5": "npm:@cloudflare/workers-types@5.20260710.1", "@sentry-internal/test-utils": "11.2.0", - "@sentry/conventions": "0.24.0", + "@sentry/conventions": "0.25.0", "eslint-plugin-regexp": "^3.1.0", "prisma": "6.15.0", "vite": "^8.3.1", diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/index.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/index.ts new file mode 100644 index 000000000000..715594b5b401 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/index.ts @@ -0,0 +1,87 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + BLOCK_CONCURRENCY_DURABLE_OBJECT: DurableObjectNamespace; + ASYNC_METHOD_DURABLE_OBJECT: DurableObjectNamespace; +} + +export class BlockConcurrencyDurableObject extends DurableObject { + private failure?: Error; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + + // The callback runs after the constructor has returned, so after the SDK has wrapped `init`. + void ctx.blockConcurrencyWhile(async () => { + try { + this.init(); + } catch (error) { + this.failure = error as Error; + } + }); + } + + init(): void { + throw new Error('Init failed'); + } + + ping(): string { + const status = this.failure ? 'degraded' : 'ok'; + Sentry.captureMessage(`block-concurrency-while ping: ${status}`); + return status; + } +} + +export class AsyncMethodDurableObject extends DurableObject { + private failure?: Error; + private readonly loaded: Promise; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + + this.loaded = this.load(); + } + + async load(): Promise { + // Everything after the first `await` runs after the constructor has returned, so after the SDK + // has wrapped `init`. + await this.ctx.storage.get('config'); + + try { + this.init(); + } catch (error) { + this.failure = error as Error; + } + } + + init(): void { + throw new Error('Init failed'); + } + + async ping(): Promise { + await this.loaded; + const status = this.failure ? 'degraded' : 'ok'; + Sentry.captureMessage(`async-method ping: ${status}`); + return status; + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === '/block-concurrency-while') { + const namespace = env.BLOCK_CONCURRENCY_DURABLE_OBJECT; + return new Response(await namespace.get(namespace.idFromName('test')).ping()); + } + + if (url.pathname === '/async-method') { + const namespace = env.ASYNC_METHOD_DURABLE_OBJECT; + return new Response(await namespace.get(namespace.idFromName('test')).ping()); + } + + return new Response('Not found', { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/instrument.server.ts @@ -0,0 +1,6 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/test.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/test.ts new file mode 100644 index 000000000000..08d34656ee5b --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/test.ts @@ -0,0 +1,48 @@ +import type { Event, SerializedStreamedSpanContainer } from '@sentry/core'; +import { SENTRY_OP, SENTRY_ORIGIN, URL_PATH } from '@sentry/conventions/attributes'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +// Every envelope of both requests is expected, and `failOnUnexpected` fails the test on any other one. +// All of them are sent after the constructor work ran, so an error or span from that work would arrive +// before the runner completes. +it('does not capture errors a Durable Object catches in work its constructor started', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + .failOnUnexpected() + .expect(envelope => { + expect(envelope[1][0][0].type).toBe('event'); + expect((envelope[1][0][1] as Event).message).toBe('block-concurrency-while ping: degraded'); + }) + .expect(envelope => { + expect(envelope[1][0][0].type).toBe('event'); + expect((envelope[1][0][1] as Event).message).toBe('async-method ping: degraded'); + }) + .expectN(2, envelope => { + expect(envelope[1][0][0].type).toBe('span'); + const container = envelope[1][0][1] as SerializedStreamedSpanContainer; + expect(container.items.map(item => item.name)).toEqual(['ping']); + expect(container.items[0]?.attributes[SENTRY_OP]?.value).toBe('rpc'); + expect(container.items[0]?.attributes[SENTRY_ORIGIN]?.value).toBe('auto.faas.cloudflare.durable_object'); + }) + .expect(envelope => { + expect(envelope[1][0][0].type).toBe('span'); + const container = envelope[1][0][1] as SerializedStreamedSpanContainer; + expect(container.items.map(item => item.attributes[URL_PATH]?.value)).toEqual(['/block-concurrency-while']); + expect(container.items[0]?.attributes[SENTRY_OP]?.value).toBe('http.server'); + }) + .expect(envelope => { + expect(envelope[1][0][0].type).toBe('span'); + const container = envelope[1][0][1] as SerializedStreamedSpanContainer; + expect(container.items.map(item => item.attributes[URL_PATH]?.value)).toEqual(['/async-method']); + expect(container.items[0]?.attributes[SENTRY_OP]?.value).toBe('http.server'); + }) + .start(signal); + + const blockConcurrencyResponse = await runner.makeRequest('get', '/block-concurrency-while'); + const asyncMethodResponse = await runner.makeRequest('get', '/async-method'); + + expect(blockConcurrencyResponse).toBe('degraded'); + expect(asyncMethodResponse).toBe('degraded'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/vite.config.mts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/wrangler.jsonc new file mode 100644 index 000000000000..952cd3f08583 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/wrangler.jsonc @@ -0,0 +1,14 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "durableobject-constructor-async-work-worker", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], + "durable_objects": { + "bindings": [ + { "name": "BLOCK_CONCURRENCY_DURABLE_OBJECT", "class_name": "BlockConcurrencyDurableObject" }, + { "name": "ASYNC_METHOD_DURABLE_OBJECT", "class_name": "AsyncMethodDurableObject" }, + ], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["BlockConcurrencyDurableObject", "AsyncMethodDurableObject"] }], +} 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 cb73392fce05..f5a787bbc91e 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,11 +1,8 @@ import { SDK_VERSION, - SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, } from '@sentry/core'; import { SENTRY_SEGMENT_NAME_SOURCE, @@ -14,6 +11,9 @@ import { SENTRY_SEGMENT_ID, SENTRY_SEGMENT_NAME, SENTRY_TRACE_LIFECYCLE, + SENTRY_ENVIRONMENT, + SENTRY_RELEASE, + SENTRY_SDK_INTEGRATIONS, } from '@sentry/conventions/attributes'; import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; @@ -88,9 +88,9 @@ it('sends a streamed span envelope with correct spans for a manually started spa [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, }, name: 'test-child-span', is_segment: false, @@ -113,8 +113,8 @@ it('sends a streamed span envelope with correct spans for a manually started spa [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, }, links: [ { @@ -150,8 +150,8 @@ it('sends a streamed span envelope with correct spans for a manually started spa [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, }, name: 'test-manual-span', is_segment: false, @@ -172,9 +172,9 @@ it('sends a streamed span envelope with correct spans for a manually started spa [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, }, name: 'test-span', is_segment: false, @@ -192,18 +192,18 @@ it('sends a streamed span envelope with correct spans for a manually started spa [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, [SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: { + [SENTRY_SDK_INTEGRATIONS]: { type: 'array', value: expect.arrayContaining(['SpanStreaming']), }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.http.cloudflare' }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'http.server' }, [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, [SENTRY_SEGMENT_NAME_SOURCE]: { type: 'string', value: 'route' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, 'server.address': { type: 'string', value: 'localhost', diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/index.ts new file mode 100644 index 000000000000..947315e12a8e --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/index.ts @@ -0,0 +1,60 @@ +import Anthropic from '@anthropic-ai/sdk'; + +// The Sentry Vite plugin instruments this worker at build time, so the `@anthropic-ai/sdk` calls below +// go through the diagnostics-channel integration (not `instrumentAnthropicAiClient`), the way an app +// built with the plugin does. + +/** A canned SSE body for a streamed message, named after the prompt so a test can tell the calls apart. */ +function streamedMessage(id: string, model: string): string { + const events = [ + { + 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' }, + ]; + return events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(''); +} + +const mockFetch: typeof fetch = async (_input, init) => { + const body = JSON.parse(String(init?.body)) as { model: string; messages: { content: string }[] }; + return new Response(streamedMessage(`msg_${body.messages[0]!.content}`, body.model), { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }); +}; + +export default { + async fetch(request) { + const client = new Anthropic({ apiKey: 'mock-api-key', fetch: mockFetch }); + const url = new URL(request.url); + + // `messages.stream()` calls `messages.create({ stream: true })` underneath, tagged with a helper + // header. Its own channel covers the call, so the nested create must not get a second span. + if (url.pathname === '/stream') { + const message = await client.messages + .stream({ model: 'claude-3-haiku-20240307', max_tokens: 10, messages: [{ role: 'user', content: 'stream' }] }) + .finalMessage(); + return Response.json(message); + } + + // `beta.messages.stream()` sends the same header, but no channel covers the beta helper, so its + // nested create is the only chance for a span and must keep it. + if (url.pathname === '/beta-stream') { + const message = await client.beta.messages + .stream({ + model: 'claude-3-haiku-20240307', + max_tokens: 10, + messages: [{ role: 'user', content: 'beta_stream' }], + }) + .finalMessage(); + return Response.json(message); + } + + return new Response('not found', { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/instrument.server.ts new file mode 100644 index 000000000000..2659d1a477a6 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/instrument.server.ts @@ -0,0 +1,10 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default defineCloudflareOptions((env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/test.ts new file mode 100644 index 000000000000..09c194076af4 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/test.ts @@ -0,0 +1,40 @@ +import { GEN_AI_RESPONSE_ID, GEN_AI_RESPONSE_STREAMING } from '@sentry/conventions/attributes'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; + +// The stream helpers of the `@anthropic-ai/sdk` tag their internal `create` with a helper header, and +// the integration skips that create only while one of its own `messages.stream()` spans is active. +// This runs that check on workerd, where the active span is the core span rather than an +// OpenTelemetry one, through the Vite plugin's build-time instrumentation. + +it('emits one span for messages.stream(), not a second one for its internal create', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + const genAiSpans = spans.filter(span => getSpanOp(span)?.startsWith('gen_ai.')); + + expect(genAiSpans).toHaveLength(1); + expect(genAiSpans[0]!.attributes[GEN_AI_RESPONSE_ID]).toEqual({ value: 'msg_stream', type: 'string' }); + expect(genAiSpans[0]!.attributes[GEN_AI_RESPONSE_STREAMING]).toEqual({ value: true, type: 'boolean' }); + }) + .start(signal); + await runner.makeRequest('get', '/stream'); + await runner.completed(); +}); + +it('emits one span for beta.messages.stream(), whose internal create is the only span', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + const genAiSpans = spans.filter(span => getSpanOp(span)?.startsWith('gen_ai.')); + + expect(genAiSpans).toHaveLength(1); + expect(genAiSpans[0]!.attributes[GEN_AI_RESPONSE_ID]).toEqual({ value: 'msg_beta_stream', type: 'string' }); + }) + .start(signal); + await runner.makeRequest('get', '/beta-stream'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/vite.config.mts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/wrangler.jsonc new file mode 100644 index 000000000000..25b432ac5751 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-anthropic-ai-stream-helper", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts index b68a41258aa0..854c492fc890 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts @@ -17,8 +17,9 @@ import { SENTRY_SEGMENT_ID, SENTRY_SEGMENT_NAME, SENTRY_TRACE_LIFECYCLE, + SENTRY_ENVIRONMENT, } from '@sentry/conventions/attributes'; -import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT } from '@sentry/core'; +import { SDK_VERSION } from '@sentry/core'; import { createRunner } from '../../../runner'; import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; @@ -71,7 +72,7 @@ it('traces a basic message creation request with the anthropic SDK', async ({ si [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, + [SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }); }) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts index 5b301a9f29ae..2cd081b06b51 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts @@ -1,17 +1,13 @@ import { expect, it } from 'vitest'; import type { Envelope, SerializedStreamedSpan } from '@sentry/core'; -import { - SDK_VERSION, - SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, -} from '@sentry/core'; +import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import { SENTRY_SDK_NAME, SENTRY_SDK_VERSION, SENTRY_SEGMENT_ID, SENTRY_SEGMENT_NAME, SENTRY_TRACE_LIFECYCLE, + SENTRY_ENVIRONMENT, } from '@sentry/conventions/attributes'; import { createRunner } from '../../../runner'; import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; @@ -38,7 +34,7 @@ function commonAttributes(segmentSpan: SerializedStreamedSpan): SerializedStream [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpan.span_id }, [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentSpan.name }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'db.query' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.db.cloudflare.d1' }, 'db.system.name': { type: 'string', value: 'cloudflare-d1' }, diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts index 9e8f12d922ba..27fc478b4667 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts @@ -24,8 +24,9 @@ import { SENTRY_SEGMENT_ID, SENTRY_SEGMENT_NAME, SENTRY_TRACE_LIFECYCLE, + SENTRY_ENVIRONMENT, } from '@sentry/conventions/attributes'; -import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT } from '@sentry/core'; +import { SDK_VERSION } from '@sentry/core'; import { createRunner } from '../../../runner'; import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; @@ -98,7 +99,7 @@ it('traces Google GenAI chat, generateContent, and embedContent calls', async ({ [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, + [SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }); @@ -138,7 +139,7 @@ it('traces Google GenAI chat, generateContent, and embedContent calls', async ({ [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, + [SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }); @@ -164,7 +165,7 @@ it('traces Google GenAI chat, generateContent, and embedContent calls', async ({ [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, + [SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }); }) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts index 7b4de87424a4..e260bfeff533 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts @@ -16,8 +16,9 @@ import { SENTRY_SEGMENT_ID, SENTRY_SEGMENT_NAME, SENTRY_TRACE_LIFECYCLE, + SENTRY_ENVIRONMENT, } from '@sentry/conventions/attributes'; -import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT } from '@sentry/core'; +import { SDK_VERSION } from '@sentry/core'; import { GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE } from '../../../../../packages/server-utils/src/ai/core/gen-ai-attributes'; import { createRunner } from '../../../runner'; import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; @@ -67,7 +68,7 @@ it('traces a LangChain chat model invocation', async ({ signal }) => { [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, + [SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }); }) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts index 0a031031a7d7..fafce0225c1e 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts @@ -17,8 +17,9 @@ import { SENTRY_SEGMENT_ID, SENTRY_SEGMENT_NAME, SENTRY_TRACE_LIFECYCLE, + SENTRY_ENVIRONMENT, } from '@sentry/conventions/attributes'; -import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT } from '@sentry/core'; +import { SDK_VERSION } from '@sentry/core'; import { createRunner } from '../../../runner'; import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; @@ -74,7 +75,7 @@ it('traces a basic chat completion request with the openai SDK', async ({ signal [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, + [SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }); }) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts index e48428c9c732..42d2d895fea0 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts @@ -34,6 +34,14 @@ export default Sentry.withSentry( return new Response(JSON.stringify(result)); } + if (url.pathname === '/evaluate') { + const result = await ai.run('typesafe/jev', { + state: 'Help! My payouts have been failing for 3 days.', + questions: { is_urgent: { type: 'noul', instructions: 'Does this convey urgency?' } }, + }); + return new Response(JSON.stringify(result)); + } + if (url.pathname === '/stream') { const stream = (await ai.run('@cf/meta/llama-3.1-8b-instruct', { messages: [{ role: 'user', content: 'What is the capital of France?' }], diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts index bce096d4ae75..e0a26d02bf86 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts @@ -23,6 +23,17 @@ export class MockAi { throw error; } + if (model === 'typesafe/jev') { + return { + state: 'Completed', + result: { + model: 'jev-1.13.0', + answers: { is_urgent: { type: 'noul', noul: 0.97 } }, + usage: { input_tokens: 426, output_tokens: 73 }, + }, + }; + } + if (inputs?.stream === true) { return createSseStream([ '{"response":"The capital "}', diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts index 726f91a5e467..6ed31514dfe4 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts @@ -5,6 +5,7 @@ import { GEN_AI_REQUEST_MAX_TOKENS, GEN_AI_REQUEST_MODEL, GEN_AI_REQUEST_TEMPERATURE, + GEN_AI_RESPONSE_MODEL, GEN_AI_RESPONSE_STREAMING, GEN_AI_RESPONSE_TEXT, GEN_AI_USAGE_INPUT_TOKENS, @@ -15,8 +16,9 @@ import { SENTRY_SEGMENT_ID, SENTRY_SEGMENT_NAME, SENTRY_TRACE_LIFECYCLE, + SENTRY_ENVIRONMENT, } from '@sentry/conventions/attributes'; -import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT } from '@sentry/core'; +import { SDK_VERSION } from '@sentry/core'; import { expect, it } from 'vitest'; import { GEN_AI_REQUEST_STREAM_ATTRIBUTE } from '../../../../../packages/server-utils/src/ai/core/gen-ai-attributes'; import { createRunner } from '../../../runner'; @@ -68,7 +70,7 @@ it('traces a basic Workers AI text generation request', async ({ signal }) => { [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, + [SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }), ); @@ -122,7 +124,7 @@ it('traces a streaming Workers AI text generation request', async ({ signal }) = [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, + [SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }), ); @@ -132,6 +134,52 @@ it('traces a streaming Workers AI text generation request', async ({ signal }) = await runner.completed(); }); +it('traces a TypeSafe Jev evaluation like the TypeSafe integration', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + + const genAiSpans = spans.filter(span => getSpanOp(span)?.startsWith('gen_ai.')); + expect(genAiSpans).toHaveLength(1); + + expect(genAiSpans[0]).toEqual( + expect.objectContaining({ + name: 'evaluate typesafe/jev', + status: 'ok', + is_segment: false, + attributes: { + 'sentry.origin': { value: 'auto.ai.cloudflare.workers_ai', type: 'string' }, + 'sentry.op': { value: 'gen_ai.evaluate', type: 'string' }, + [GEN_AI_PROVIDER_NAME]: { value: 'cloudflare.workers_ai', type: 'string' }, + [GEN_AI_OPERATION_NAME]: { value: 'evaluate', type: 'string' }, + [GEN_AI_REQUEST_MODEL]: { value: 'typesafe/jev', type: 'string' }, + [GEN_AI_RESPONSE_MODEL]: { value: 'jev-1.13.0', type: 'string' }, + [GEN_AI_USAGE_INPUT_TOKENS]: { value: 426, type: 'integer' }, + [GEN_AI_USAGE_OUTPUT_TOKENS]: { value: 73, type: 'integer' }, + [GEN_AI_USAGE_TOTAL_TOKENS]: { value: 499, type: 'integer' }, + // collect only output messages + [GEN_AI_OUTPUT_MESSAGES]: { + type: 'string', + value: '[{"type":"evaluation","answers":{"is_urgent":{"type":"noul","noul":0.97}}}]', + }, + 'sentry.is_localhost': { value: true, type: 'boolean' }, + [SENTRY_TRACE_LIFECYCLE]: { value: 'stream', type: 'string' }, + [SENTRY_SEGMENT_NAME]: { value: segmentSpan!.name, type: 'string' }, + [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, + [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, + [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, + [SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, + }, + }), + ); + }) + .start(signal); + await runner.makeRequest('get', '/evaluate'); + await runner.completed(); +}); + // The Workers AI integration deliberately does not call `captureException` itself. // When a `run` call fails, the error must bubble up out of the fetch handler and be // reported by the top-level Cloudflare instrumentation instead — so it shows up in diff --git a/dev-packages/deno-integration-tests/node-suites/excludes.ts b/dev-packages/deno-integration-tests/node-suites/excludes.ts index e2b5e0cd5b01..36098da333df 100644 --- a/dev-packages/deno-integration-tests/node-suites/excludes.ts +++ b/dev-packages/deno-integration-tests/node-suites/excludes.ts @@ -49,6 +49,7 @@ const REQUIRE_OF_ESM_ONLY_DEPENDENCY = [ // version) and pass on Deno 2.9.0. const NOT_TRIAGED = [ 'suites/tracing/anthropic/test.ts', + 'suites/tracing/anthropic/v0.129/test.ts', 'suites/tracing/apollo-graphql/**', 'suites/tracing/fastify/test.ts', 'suites/tracing/flue/test.ts', diff --git a/dev-packages/deno-integration-tests/src/index.ts b/dev-packages/deno-integration-tests/src/index.ts index 5268dfd42a8a..3c18c1154838 100644 --- a/dev-packages/deno-integration-tests/src/index.ts +++ b/dev-packages/deno-integration-tests/src/index.ts @@ -72,6 +72,11 @@ export function transactionSink(): { }; } +export function getSpanOp(span: SerializedStreamedSpan): string | undefined { + const attribute = span.attributes['sentry.op']; + return attribute?.type === 'string' ? attribute.value : undefined; +} + /** * A `beforeSend` hook that records every error and lets a test * `await` the first one matching a predicate. `waitFor` resolves immediately if @@ -98,18 +103,22 @@ export function withTimeout(p: Promise, ms: number, what: string): Promise * (returning null is disallowed), so intercepting them needs a transport rather than a hook. */ export function spanSink(): { + spans: SerializedStreamedSpan[]; waitFor: (predicate: (span: SerializedStreamedSpan) => boolean) => Promise; transport: () => Transport; } { + const spans: SerializedStreamedSpan[] = []; const sink = eventSink(); return { + spans, waitFor: sink.waitFor, transport: () => ({ send: (envelope: Envelope) => { for (const [header, payload] of envelope[1]) { if (header.type === 'span') { for (const span of (payload as SerializedStreamedSpanContainer).items) { + spans.push(span); sink.beforeSend(span); } } diff --git a/dev-packages/deno-integration-tests/suites/deno-serve-request-bodies/test.ts b/dev-packages/deno-integration-tests/suites/deno-serve-request-bodies/test.ts index 49e732281bde..53f0c55877f8 100644 --- a/dev-packages/deno-integration-tests/suites/deno-serve-request-bodies/test.ts +++ b/dev-packages/deno-integration-tests/suites/deno-serve-request-bodies/test.ts @@ -1,18 +1,17 @@ // -import { denoServeIntegration, init } from '@sentry/deno'; +import { denoServeIntegration, flush, init } from '@sentry/deno'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('Deno.serve captures incoming request bodies by default', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const requestBody = 'captured-by-default'; @@ -25,31 +24,37 @@ Deno.test('Deno.serve captures incoming request bodies by default', async () => }); await listening; - const transactionPromise = withTimeout( - sink.waitFor(event => event.request?.url?.endsWith('/default') === true), - 5_000, - 'transaction for /default', - ); - const response = await fetch(`http://localhost:${server.addr.port}/default`, { - method: 'POST', - headers: { 'content-type': 'text/plain' }, - body: requestBody, - }); - assertEquals(await response.text(), 'OK'); - - const transaction = await transactionPromise; - assertEquals(transaction.request?.data, requestBody); - - abortController.abort(); - await server.finished; + try { + const spanPromise = withTimeout( + sink.waitFor( + span => + span.is_segment && getSpanOp(span) === 'http.server' && span.attributes['url.path']?.value === '/default', + ), + 5_000, + 'segment span for /default', + ); + const response = await fetch(`http://localhost:${server.addr.port}/default`, { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: requestBody, + }); + assertEquals(await response.text(), 'OK'); + + await flush(); + const span = await spanPromise; + assertEquals(span.name, 'POST'); + assertEquals(span.attributes['http.request.body.data']?.value, requestBody); + } finally { + abortController.abort(); + await server.finished; + } }); Deno.test('Deno.serve explicit small overrides disabled incoming request body collection', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, dataCollection: { httpBodies: [] }, @@ -57,7 +62,7 @@ Deno.test('Deno.serve explicit small overrides disabled incoming request body co ...integrations.filter(integration => integration.name !== 'DenoServe'), denoServeIntegration({ maxRequestBodySize: 'small' }), ], - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const requestBody = 'a'.repeat(1_001); @@ -71,31 +76,39 @@ Deno.test('Deno.serve explicit small overrides disabled incoming request body co }); await listening; - const transactionPromise = withTimeout( - sink.waitFor(event => event.request?.url?.endsWith('/explicit-small') === true), - 5_000, - 'transaction for /explicit-small', - ); - const response = await fetch(`http://localhost:${server.addr.port}/explicit-small`, { - method: 'POST', - headers: { 'content-type': 'text/plain' }, - body: requestBody, - }); - assertEquals(await response.text(), 'OK'); - - const transaction = await transactionPromise; - assertEquals(transaction.request?.data, expectedBody); - - abortController.abort(); - await server.finished; + try { + const spanPromise = withTimeout( + sink.waitFor( + span => + span.is_segment && + getSpanOp(span) === 'http.server' && + span.attributes['url.path']?.value === '/explicit-small', + ), + 5_000, + 'segment span for /explicit-small', + ); + const response = await fetch(`http://localhost:${server.addr.port}/explicit-small`, { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: requestBody, + }); + assertEquals(await response.text(), 'OK'); + + await flush(); + const span = await spanPromise; + assertEquals(span.name, 'POST'); + assertEquals(span.attributes['http.request.body.data']?.value, expectedBody); + } finally { + abortController.abort(); + await server.finished; + } }); Deno.test('Deno.serve explicit none overrides enabled incoming request body collection', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, dataCollection: { httpBodies: ['incomingRequest'] }, @@ -103,7 +116,7 @@ Deno.test('Deno.serve explicit none overrides enabled incoming request body coll ...integrations.filter(integration => integration.name !== 'DenoServe'), denoServeIntegration({ maxRequestBodySize: 'none' }), ], - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const abortController = new AbortController(); @@ -115,21 +128,30 @@ Deno.test('Deno.serve explicit none overrides enabled incoming request body coll }); await listening; - const transactionPromise = withTimeout( - sink.waitFor(event => event.request?.url?.endsWith('/explicit-none') === true), - 5_000, - 'transaction for /explicit-none', - ); - const response = await fetch(`http://localhost:${server.addr.port}/explicit-none`, { - method: 'POST', - headers: { 'content-type': 'text/plain' }, - body: 'do-not-capture', - }); - assertEquals(await response.text(), 'OK'); - - const transaction = await transactionPromise; - assertEquals(transaction.request?.data, undefined); - - abortController.abort(); - await server.finished; + try { + const spanPromise = withTimeout( + sink.waitFor( + span => + span.is_segment && + getSpanOp(span) === 'http.server' && + span.attributes['url.path']?.value === '/explicit-none', + ), + 5_000, + 'segment span for /explicit-none', + ); + const response = await fetch(`http://localhost:${server.addr.port}/explicit-none`, { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: 'do-not-capture', + }); + assertEquals(await response.text(), 'OK'); + + await flush(); + const span = await spanPromise; + assertEquals(span.name, 'POST'); + assertEquals(span.attributes['http.request.body.data']?.value, undefined); + } finally { + abortController.abort(); + await server.finished; + } }); diff --git a/dev-packages/deno-integration-tests/suites/direct-client-acs/scenario.mjs b/dev-packages/deno-integration-tests/suites/direct-client-acs/scenario.mjs index 1dc48ebb60c2..3b045ec0829e 100644 --- a/dev-packages/deno-integration-tests/suites/direct-client-acs/scenario.mjs +++ b/dev-packages/deno-integration-tests/suites/direct-client-acs/scenario.mjs @@ -12,22 +12,24 @@ import { nodeStackLineParser } from '@sentry/core/server'; import { DenoClient, getCurrentScope, getDefaultIntegrations, startSpan } from '@sentry/deno'; import { tracingChannel } from 'node:diagnostics_channel'; -let nested = false; +const spans = []; const client = new DenoClient({ dsn: 'https://username@domain/123', tracesSampleRate: 1, - traceLifecycle: 'static', integrations: getDefaultIntegrations({ tracesSampleRate: 1 }), stackParser: createStackParser(nodeStackLineParser()), - beforeSendTransaction(event) { - const spans = event.spans ?? []; - if (spans.some(s => s.op === 'db' && s.data?.['sentry.origin'] === 'auto.db.mysql')) { - nested = true; - } - return null; - }, - transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }), + transport: () => ({ + send(envelope) { + for (const [header, payload] of envelope[1]) { + if (header.type === 'span') { + spans.push(...payload.items); + } + } + return Promise.resolve({}); + }, + flush: () => Promise.resolve(true), + }), }); client.init(); @@ -50,5 +52,14 @@ startSpan({ name: 'parent', op: 'test' }, () => { await client.flush(2000); +const parent = spans.find(span => span.is_segment && span.name === 'parent'); +const nested = spans.some( + span => + span.attributes['sentry.op']?.value === 'db' && + span.attributes['sentry.origin']?.value === 'auto.db.mysql' && + span.parent_span_id === parent?.span_id && + span.trace_id === parent?.trace_id, +); + // eslint-disable-next-line no-console console.log(`SCENARIO nested=${nested}`); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-amqplib/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-amqplib/test.ts index d059cb76971c..9c1031ed9ee3 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-amqplib/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-amqplib/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('amqplib instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -21,12 +20,11 @@ Deno.test('amqplib instrumentation: included in default integrations (Deno 2.8.0 Deno.test('amqplib instrumentation: orchestrion:amqplib:publish channel produces a nested queue.publish span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:amqplib:publish'); @@ -47,16 +45,25 @@ Deno.test('amqplib instrumentation: orchestrion:amqplib:publish channel produces }); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const publishSpan = parent.spans?.find(s => s.op === 'queue.publish'); - assertExists(publishSpan, `expected a queue.publish child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(publishSpan!.description, 'publish my-exchange'); - assertEquals(publishSpan!.data?.['messaging.destination.name'], 'my-exchange'); - assertEquals(publishSpan!.data?.['messaging.system'], 'rabbitmq'); - assertEquals(publishSpan!.data?.['sentry.origin'], 'auto.amqplib.publisher'); + const publishSpan = children.find(s => getSpanOp(s) === 'queue.publish'); + assertExists( + publishSpan, + `expected a queue.publish child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`, + ); + assertEquals(publishSpan.name, 'send my-exchange'); + assertEquals(publishSpan.attributes['messaging.destination.name']?.value, 'my-exchange'); + assertEquals(publishSpan.attributes['messaging.system']?.value, 'rabbitmq'); + assertEquals(publishSpan.attributes['sentry.origin']?.value, 'auto.amqplib.publisher'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-anthropic/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-anthropic/test.ts index 9372fd70934f..d4dc830311fc 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-anthropic/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-anthropic/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('anthropic instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -21,12 +20,11 @@ Deno.test('anthropic instrumentation: included in default integrations (Deno 2.8 Deno.test('anthropic instrumentation: orchestrion @anthropic-ai/sdk:chat channel produces a nested gen_ai span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:@anthropic-ai/sdk:chat'); @@ -46,19 +44,25 @@ Deno.test('anthropic instrumentation: orchestrion @anthropic-ai/sdk:chat channel channel.asyncEnd.publish(ctx); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const aiSpan = parent.spans?.find(s => s.op === 'gen_ai.chat'); - assertExists(aiSpan, `expected a gen_ai.chat child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(aiSpan!.description, 'chat claude-3-5-sonnet-latest'); - assertEquals(aiSpan!.data?.['gen_ai.provider.name'], 'anthropic'); - assertEquals(aiSpan!.data?.['gen_ai.operation.name'], 'chat'); - assertEquals(aiSpan!.data?.['gen_ai.request.model'], 'claude-3-5-sonnet-latest'); - assertEquals(aiSpan!.data?.['gen_ai.response.model'], 'claude-3-5-sonnet-20241022'); - assertEquals(aiSpan!.data?.['gen_ai.usage.total_tokens'], 15); - assertEquals(aiSpan!.data?.['sentry.origin'], 'auto.ai.anthropic'); + const aiSpan = children.find(s => getSpanOp(s) === 'gen_ai.chat'); + assertExists(aiSpan, `expected a gen_ai.chat child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`); + assertEquals(aiSpan.name, 'chat claude-3-5-sonnet-latest'); + assertEquals(aiSpan.attributes['gen_ai.provider.name']?.value, 'anthropic'); + assertEquals(aiSpan.attributes['gen_ai.operation.name']?.value, 'chat'); + assertEquals(aiSpan.attributes['gen_ai.request.model']?.value, 'claude-3-5-sonnet-latest'); + assertEquals(aiSpan.attributes['gen_ai.response.model']?.value, 'claude-3-5-sonnet-20241022'); + assertEquals(aiSpan.attributes['gen_ai.usage.total_tokens']?.value, 15); + assertEquals(aiSpan.attributes['sentry.origin']?.value, 'auto.ai.anthropic'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-aws/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-aws/test.ts index b07fb3da9b31..1beb142f3313 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-aws/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-aws/test.ts @@ -3,17 +3,16 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { Span } from '@sentry/core'; import type { DenoClient } from '@sentry/deno'; -import { init, spanToJSON, startSpan } from '@sentry/deno'; +import { flush, init, spanToJSON, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; import { SENTRY_OP } from '@sentry/conventions/attributes'; Deno.test('aws-sdk instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -29,17 +28,16 @@ Deno.test('aws-sdk instrumentation: included in default integrations (Deno 2.8.0 // the parent span is held open until the `spanEnd` hook reports the child ended. Deno.test('aws-sdk instrumentation: orchestrion @smithy/smithy-client:send channel produces a nested rpc span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }) as DenoClient; // The rpc span ends only after the deferred region backfill settles. Wait on the // concrete `spanEnd` signal rather than a timer, so the parent stays open until the - // child has actually ended and can be captured on the transaction. + // child has actually ended before flushing the span buffer. const rpcSpanEnded = new Promise(resolve => { client.on('spanEnd', (span: Span) => { if (spanToJSON(span).attributes[SENTRY_OP] === 'rpc') { @@ -71,18 +69,24 @@ Deno.test('aws-sdk instrumentation: orchestrion @smithy/smithy-client:send chann await rpcSpanEnded; }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const awsSpan = parent.spans?.find(s => s.op === 'rpc'); - assertExists(awsSpan, `expected an rpc child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(awsSpan!.description, 'CloudWatch.DescribeAlarms'); - assertEquals(awsSpan!.data?.['rpc.system'], 'aws-api'); - assertEquals(awsSpan!.data?.['rpc.service'], 'CloudWatch'); - assertEquals(awsSpan!.data?.['rpc.method'], 'DescribeAlarms'); - assertEquals(awsSpan!.data?.['cloud.region'], 'us-east-1'); - assertEquals(awsSpan!.data?.['sentry.origin'], 'auto.aws.aws_sdk'); + const awsSpan = children.find(s => getSpanOp(s) === 'rpc'); + assertExists(awsSpan, `expected an rpc child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`); + assertEquals(awsSpan.name, 'CloudWatch.DescribeAlarms'); + assertEquals(awsSpan.attributes['rpc.system']?.value, 'aws-api'); + assertEquals(awsSpan.attributes['rpc.service']?.value, 'CloudWatch'); + assertEquals(awsSpan.attributes['rpc.method']?.value, 'DescribeAlarms'); + assertEquals(awsSpan.attributes['cloud.region']?.value, 'us-east-1'); + assertEquals(awsSpan.attributes['sentry.origin']?.value, 'auto.aws.aws_sdk'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-express/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-express/test.ts index 7fb2aafd0bf6..1b480f847fae 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-express/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-express/test.ts @@ -3,27 +3,26 @@ import { EventEmitter } from 'node:events'; import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('express instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); - const client = init({ traceLifecycle: 'static', dsn: 'https://username@domain/123' }) as DenoClient; + const client = init({ dsn: 'https://username@domain/123' }) as DenoClient; const names = client.getOptions().integrations.map(i => i.name); assert(names.includes('Express'), `Express should be in defaults, got ${names.join(', ')}`); }); Deno.test('express instrumentation: orchestrion:express:handle channel produces a nested middleware span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:express:handle'); @@ -40,16 +39,25 @@ Deno.test('express instrumentation: orchestrion:express:handle channel produces channel.asyncEnd.publish(ctx); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); - - const expressSpan = parent.spans?.find(s => s.op === 'middleware'); - assertExists(expressSpan, `expected an express middleware span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(expressSpan!.description, 'myMiddleware'); - assertEquals(expressSpan!.data?.['express.name'], 'myMiddleware'); - assertEquals(expressSpan!.data?.['express.type'], 'middleware'); - assertEquals(expressSpan!.data?.['sentry.origin'], 'auto.http.express'); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); + + const expressSpan = children.find(s => getSpanOp(s) === 'middleware'); + assertExists( + expressSpan, + `expected an express middleware span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`, + ); + assertEquals(expressSpan.name, 'myMiddleware'); + assertEquals(expressSpan.attributes['express.name']?.value, 'myMiddleware'); + assertEquals(expressSpan.attributes['express.type']?.value, 'middleware'); + assertEquals(expressSpan.attributes['sentry.origin']?.value, 'auto.http.express'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-fastify/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-fastify/test.ts index 276a74eda91c..a6f0f4fbdcd1 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-fastify/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-fastify/test.ts @@ -10,7 +10,7 @@ import { errorSink, resetGlobals, withTimeout } from '../../src/index.ts'; Deno.test('fastify instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); - const client = init({ traceLifecycle: 'static', dsn: 'https://username@domain/123' }) as DenoClient; + const client = init({ dsn: 'https://username@domain/123' }) as DenoClient; const names = client.getOptions().integrations.map(i => i.name); assert(names.includes('Fastify'), `Fastify should be in defaults, got ${names.join(', ')}`); }); @@ -19,7 +19,6 @@ Deno.test('fastify instrumentation: tracing:fastify.request.handler:error channe resetGlobals(); const sink = errorSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', beforeSend: sink.beforeSend, }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-firebase/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-firebase/test.ts index 6d4d3e6ff87a..4f6440b8a677 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-firebase/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-firebase/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('firebase instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -21,12 +20,11 @@ Deno.test('firebase instrumentation: included in default integrations (Deno 2.8. Deno.test('firebase instrumentation: orchestrion @firebase/firestore:add-doc channel produces a nested db span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:@firebase/firestore:add-doc'); @@ -52,19 +50,25 @@ Deno.test('firebase instrumentation: orchestrion @firebase/firestore:add-doc cha channel.asyncEnd.publish(ctx); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const fsSpan = parent.spans?.find(s => s.op === 'db.query'); - assertExists(fsSpan, `expected a db.query child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(fsSpan!.description, 'addDoc users'); - assertEquals(fsSpan!.data?.['db.operation.name'], 'addDoc'); - assertEquals(fsSpan!.data?.['db.collection.name'], 'users'); - assertEquals(fsSpan!.data?.['db.namespace'], '[DEFAULT]'); - assertEquals(fsSpan!.data?.['db.system.name'], 'firebase.firestore'); - assertEquals(fsSpan!.data?.['firebase.firestore.options.projectId'], 'demo-project'); - assertEquals(fsSpan!.data?.['sentry.origin'], 'auto.firebase.firestore'); + const fsSpan = children.find(s => getSpanOp(s) === 'db.query'); + assertExists(fsSpan, `expected a db.query child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`); + assertEquals(fsSpan.name, 'addDoc users'); + assertEquals(fsSpan.attributes['db.operation.name']?.value, 'addDoc'); + assertEquals(fsSpan.attributes['db.collection.name']?.value, 'users'); + assertEquals(fsSpan.attributes['db.namespace']?.value, '[DEFAULT]'); + assertEquals(fsSpan.attributes['db.system.name']?.value, 'firebase.firestore'); + assertEquals(fsSpan.attributes['firebase.firestore.options.projectId']?.value, 'demo-project'); + assertEquals(fsSpan.attributes['sentry.origin']?.value, 'auto.firebase.firestore'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-generic-pool/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-generic-pool/test.ts index 2f5258935c00..4733848fe2e3 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-generic-pool/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-generic-pool/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('generic-pool instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -21,12 +20,11 @@ Deno.test('generic-pool instrumentation: included in default integrations (Deno Deno.test('generic-pool instrumentation: orchestrion:generic-pool:acquire channel produces a nested span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:generic-pool:acquire'); @@ -43,17 +41,20 @@ Deno.test('generic-pool instrumentation: orchestrion:generic-pool:acquire channe }); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", - ); - - const poolSpan = parent.spans?.find(s => s.description === 'generic-pool.acquire'); - assertExists( - poolSpan, - `expected a generic-pool.acquire span, got descriptions: ${parent.spans?.map(s => s.description).join(', ')}`, + "'parent' segment span", ); - assertEquals(poolSpan!.data?.['sentry.origin'], 'auto.db.generic_pool'); - assertEquals(poolSpan!.op, 'db'); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); + + const poolSpan = children.find(s => s.name === 'generic-pool.acquire'); + assertExists(poolSpan, `expected a generic-pool.acquire span, got names: ${children.map(s => s.name).join(', ')}`); + assertEquals(poolSpan.attributes['sentry.origin']?.value, 'auto.db.generic_pool'); + assertEquals(getSpanOp(poolSpan), 'db'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-google-genai/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-google-genai/test.ts index db193197e0f5..e28930b42161 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-google-genai/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-google-genai/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('google-genai instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -21,12 +20,11 @@ Deno.test('google-genai instrumentation: included in default integrations (Deno Deno.test('google-genai instrumentation: orchestrion @google/genai:generate-content channel produces a nested gen_ai span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:@google/genai:generate-content'); @@ -45,22 +43,28 @@ Deno.test('google-genai instrumentation: orchestrion @google/genai:generate-cont channel.asyncEnd.publish(ctx); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const aiSpan = parent.spans?.find(s => s.op === 'gen_ai.generate_content'); + const aiSpan = children.find(s => getSpanOp(s) === 'gen_ai.generate_content'); assertExists( aiSpan, - `expected a gen_ai.generate_content child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`, + `expected a gen_ai.generate_content child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`, ); - assertEquals(aiSpan!.description, 'generate_content gemini-1.5-flash'); - assertEquals(aiSpan!.data?.['gen_ai.provider.name'], 'google_genai'); - assertEquals(aiSpan!.data?.['gen_ai.operation.name'], 'generate_content'); - assertEquals(aiSpan!.data?.['gen_ai.request.model'], 'gemini-1.5-flash'); - assertEquals(aiSpan!.data?.['gen_ai.response.model'], 'gemini-1.5-flash-002'); - assertEquals(aiSpan!.data?.['gen_ai.usage.total_tokens'], 15); - assertEquals(aiSpan!.data?.['sentry.origin'], 'auto.ai.google_genai'); + assertEquals(aiSpan.name, 'generate_content gemini-1.5-flash'); + assertEquals(aiSpan.attributes['gen_ai.provider.name']?.value, 'google_genai'); + assertEquals(aiSpan.attributes['gen_ai.operation.name']?.value, 'generate_content'); + assertEquals(aiSpan.attributes['gen_ai.request.model']?.value, 'gemini-1.5-flash'); + assertEquals(aiSpan.attributes['gen_ai.response.model']?.value, 'gemini-1.5-flash-002'); + assertEquals(aiSpan.attributes['gen_ai.usage.total_tokens']?.value, 15); + assertEquals(aiSpan.attributes['sentry.origin']?.value, 'auto.ai.google_genai'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-graphql/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-graphql/test.ts index a352bd20b79d..2497b4f6eef9 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-graphql/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-graphql/test.ts @@ -16,6 +16,7 @@ async function assertParseSpan(channelName: string): Promise { resetGlobals(); const sink = transactionSink(); init({ + // Keep one integration suite on the static lifecycle as a regression guard. traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, @@ -49,7 +50,6 @@ async function assertParseSpan(channelName: string): Promise { Deno.test('graphql instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-hapi/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-hapi/test.ts index af0a9c92a0c4..28a1c2e49f06 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-hapi/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-hapi/test.ts @@ -2,27 +2,26 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('hapi instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); - const client = init({ traceLifecycle: 'static', dsn: 'https://username@domain/123' }) as DenoClient; + const client = init({ dsn: 'https://username@domain/123' }) as DenoClient; const names = client.getOptions().integrations.map(i => i.name); assert(names.includes('Hapi'), `Hapi should be in defaults, got ${names.join(', ')}`); }); Deno.test('hapi instrumentation: orchestrion:@hapi/hapi:route channel wraps the route handler into a span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); // `start` wraps the route's `handler` in place; the span opens when that @@ -36,16 +35,22 @@ Deno.test('hapi instrumentation: orchestrion:@hapi/hapi:route channel wraps the wrappedRoute.handler({}, {}); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const hapiSpan = parent.spans?.find(s => s.op === 'router'); - assertExists(hapiSpan, `expected a router span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(hapiSpan!.description, 'GET /hello'); - assertEquals(hapiSpan!.data?.['hapi.type'], 'router'); - assertEquals(hapiSpan!.data?.['http.route'], '/hello'); - assertEquals(hapiSpan!.data?.['sentry.origin'], 'auto.http.hapi'); + const hapiSpan = children.find(s => getSpanOp(s) === 'router'); + assertExists(hapiSpan, `expected a router span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`); + assertEquals(hapiSpan.name, '/hello'); + assertEquals(hapiSpan.attributes['hapi.type']?.value, 'router'); + assertEquals(hapiSpan.attributes['http.route']?.value, '/hello'); + assertEquals(hapiSpan.attributes['sentry.origin']?.value, 'auto.http.hapi'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-kafkajs/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-kafkajs/test.ts index d94839209bd1..0265468722a3 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-kafkajs/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-kafkajs/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('kafkajs instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -21,12 +20,11 @@ Deno.test('kafkajs instrumentation: included in default integrations (Deno 2.8.0 Deno.test('kafkajs instrumentation: orchestrion:kafkajs:send_batch channel produces a nested producer span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:kafkajs:send_batch'); @@ -39,16 +37,25 @@ Deno.test('kafkajs instrumentation: orchestrion:kafkajs:send_batch channel produ channel.asyncEnd.publish(ctx); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); - - const kafkaSpan = parent.spans?.find(s => s.op === 'queue.publish'); - assertExists(kafkaSpan, `expected a queue.publish child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(kafkaSpan!.description, 'send my-topic'); - assertEquals(kafkaSpan!.data?.['messaging.system'], 'kafka'); - assertEquals(kafkaSpan!.data?.['messaging.destination.name'], 'my-topic'); - assertEquals(kafkaSpan!.data?.['sentry.origin'], 'auto.kafkajs.producer'); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); + + const kafkaSpan = children.find(s => getSpanOp(s) === 'queue.publish'); + assertExists( + kafkaSpan, + `expected a queue.publish child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`, + ); + assertEquals(kafkaSpan.name, 'send my-topic'); + assertEquals(kafkaSpan.attributes['messaging.system']?.value, 'kafka'); + assertEquals(kafkaSpan.attributes['messaging.destination.name']?.value, 'my-topic'); + assertEquals(kafkaSpan.attributes['sentry.origin']?.value, 'auto.kafkajs.producer'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-koa/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-koa/test.ts index e084e4cd136a..d7bb9b689efa 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-koa/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-koa/test.ts @@ -2,27 +2,26 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('koa instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); - const client = init({ traceLifecycle: 'static', dsn: 'https://username@domain/123' }) as DenoClient; + const client = init({ dsn: 'https://username@domain/123' }) as DenoClient; const names = client.getOptions().integrations.map(i => i.name); assert(names.includes('Koa'), `Koa should be in defaults, got ${names.join(', ')}`); }); Deno.test('koa instrumentation: orchestrion:koa:use channel wraps middleware into a span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); function myMiddleware(_context: unknown, next: () => Promise): Promise { @@ -38,14 +37,20 @@ Deno.test('koa instrumentation: orchestrion:koa:use channel wraps middleware int await wrappedMiddleware({}, () => Promise.resolve()); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); - - const koaSpan = parent.spans?.find(s => s.op === 'middleware'); - assertExists(koaSpan, `expected a koa middleware child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(koaSpan!.description, 'myMiddleware'); - assertEquals(koaSpan!.data?.['sentry.origin'], 'auto.http.koa'); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); + + const koaSpan = children.find(s => getSpanOp(s) === 'middleware'); + assertExists(koaSpan, `expected a koa middleware child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`); + assertEquals(koaSpan.name, 'myMiddleware'); + assertEquals(koaSpan.attributes['sentry.origin']?.value, 'auto.http.koa'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-langchain/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-langchain/test.ts index 26d5da42cb0f..a0b7782f2779 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-langchain/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-langchain/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('langchain instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -21,12 +20,11 @@ Deno.test('langchain instrumentation: included in default integrations (Deno 2.8 Deno.test('langchain instrumentation: orchestrion @langchain/openai:embedQuery channel produces a nested embeddings span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:@langchain/openai:embedQuery'); @@ -43,17 +41,26 @@ Deno.test('langchain instrumentation: orchestrion @langchain/openai:embedQuery c channel.asyncEnd.publish(ctx); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const aiSpan = parent.spans?.find(s => s.op === 'gen_ai.embeddings'); - assertExists(aiSpan, `expected a gen_ai.embeddings child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(aiSpan!.description, 'embeddings text-embedding-3-small'); - assertEquals(aiSpan!.data?.['gen_ai.provider.name'], 'openai'); - assertEquals(aiSpan!.data?.['gen_ai.operation.name'], 'embeddings'); - assertEquals(aiSpan!.data?.['gen_ai.request.model'], 'text-embedding-3-small'); - assertEquals(aiSpan!.data?.['sentry.origin'], 'auto.ai.langchain'); + const aiSpan = children.find(s => getSpanOp(s) === 'gen_ai.embeddings'); + assertExists( + aiSpan, + `expected a gen_ai.embeddings child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`, + ); + assertEquals(aiSpan.name, 'embeddings text-embedding-3-small'); + assertEquals(aiSpan.attributes['gen_ai.provider.name']?.value, 'openai'); + assertEquals(aiSpan.attributes['gen_ai.operation.name']?.value, 'embeddings'); + assertEquals(aiSpan.attributes['gen_ai.request.model']?.value, 'text-embedding-3-small'); + assertEquals(aiSpan.attributes['sentry.origin']?.value, 'auto.ai.langchain'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-langgraph/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-langgraph/test.ts index e1045407beb5..b10c4f1ed48d 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-langgraph/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-langgraph/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('langgraph instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -21,12 +20,11 @@ Deno.test('langgraph instrumentation: included in default integrations (Deno 2.8 Deno.test('langgraph instrumentation: orchestrion stateGraphCompile channel wraps the compiled graph invoke', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:@langchain/langgraph:stateGraphCompile'); @@ -44,19 +42,25 @@ Deno.test('langgraph instrumentation: orchestrion stateGraphCompile channel wrap await compiledGraph.invoke(); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const invokeAgentSpan = parent.spans?.find(s => s.op === 'gen_ai.invoke_agent'); + const invokeAgentSpan = children.find(s => getSpanOp(s) === 'gen_ai.invoke_agent'); assertExists( invokeAgentSpan, - `expected a gen_ai.invoke_agent child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`, + `expected a gen_ai.invoke_agent child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`, ); - assertEquals(invokeAgentSpan!.description, 'invoke_agent my-agent'); - assertEquals(invokeAgentSpan!.data?.['gen_ai.operation.name'], 'invoke_agent'); - assertEquals(invokeAgentSpan!.data?.['gen_ai.agent.name'], 'my-agent'); - assertEquals(invokeAgentSpan!.data?.['sentry.origin'], 'auto.ai.langgraph'); + assertEquals(invokeAgentSpan.name, 'invoke_agent my-agent'); + assertEquals(invokeAgentSpan.attributes['gen_ai.operation.name']?.value, 'invoke_agent'); + assertEquals(invokeAgentSpan.attributes['gen_ai.agent.name']?.value, 'my-agent'); + assertEquals(invokeAgentSpan.attributes['sentry.origin']?.value, 'auto.ai.langgraph'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-lru-memoizer/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-lru-memoizer/test.ts index acff0649c8a7..9c2ba2971f11 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-lru-memoizer/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-lru-memoizer/test.ts @@ -3,16 +3,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { Span } from '@sentry/core'; import type { DenoClient } from '@sentry/deno'; -import { getActiveSpan, init, startSpan, startSpanManual } from '@sentry/deno'; +import { flush, getActiveSpan, init, startSpan, startSpanManual } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('lru-memoizer instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -28,12 +27,11 @@ Deno.test('lru-memoizer instrumentation: included in default integrations (Deno // new trace; with it, the parent is active again and a span nests under it. Deno.test('lru-memoizer instrumentation: restores the caller scope onto the memoized callback', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:lru-memoizer:load'); @@ -61,16 +59,19 @@ Deno.test('lru-memoizer instrumentation: restores the caller scope onto the memo // The callback saw the caller's span restored. assertEquals(restoredActive, parentSpan); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); // The span created in the restored callback nested under the caller, not a new trace. - const child = parent.spans?.find(s => s.description === 'memoized-work'); - assertExists( - child, - `expected memoized-work nested under parent, got: ${parent.spans?.map(s => s.description).join(', ')}`, - ); + const child = children.find(s => s.name === 'memoized-work'); + assertExists(child, `expected memoized-work nested under parent, got: ${children.map(s => s.name).join(', ')}`); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-mongo/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-mongo/test.ts index f9693fc07ef9..d7a482dbdaeb 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-mongo/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-mongo/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('mongodb instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -21,12 +20,11 @@ Deno.test('mongodb instrumentation: included in default integrations (Deno 2.8.0 Deno.test('mongodb instrumentation: orchestrion:mongodb:command channel produces a nested db span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:mongodb:command'); @@ -50,21 +48,27 @@ Deno.test('mongodb instrumentation: orchestrion:mongodb:command channel produces }); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const mongoSpan = parent.spans?.find(s => s.op === 'db'); - assertExists(mongoSpan, `expected a db child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(mongoSpan!.description, '{"find":"?","filter":{"name":"?"}}'); - assertEquals(mongoSpan!.data?.['db.system.name'], 'mongodb'); - assertEquals(mongoSpan!.data?.['db.query.text'], '{"find":"?","filter":{"name":"?"}}'); - assertEquals(mongoSpan!.data?.['db.namespace'], 'mydb'); - assertEquals(mongoSpan!.data?.['db.collection.name'], 'users'); - assertEquals(mongoSpan!.data?.['db.operation.name'], 'find'); - assertEquals(mongoSpan!.data?.['server.address'], '127.0.0.1'); - assertEquals(mongoSpan!.data?.['server.port'], 27017); - assertEquals(mongoSpan!.data?.['sentry.origin'], 'auto.db.mongo'); + const mongoSpan = children.find(s => getSpanOp(s) === 'db'); + assertExists(mongoSpan, `expected a db child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`); + assertEquals(mongoSpan.name, 'find users'); + assertEquals(mongoSpan.attributes['db.system.name']?.value, 'mongodb'); + assertEquals(mongoSpan.attributes['db.query.text']?.value, '{"find":"?","filter":{"name":"?"}}'); + assertEquals(mongoSpan.attributes['db.namespace']?.value, 'mydb'); + assertEquals(mongoSpan.attributes['db.collection.name']?.value, 'users'); + assertEquals(mongoSpan.attributes['db.operation.name']?.value, 'find'); + assertEquals(mongoSpan.attributes['server.address']?.value, '127.0.0.1'); + assertEquals(mongoSpan.attributes['server.port']?.value, 27017); + assertEquals(mongoSpan.attributes['sentry.origin']?.value, 'auto.db.mongo'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-mongoose/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-mongoose/test.ts index 46854c29b07a..1d3cb1033562 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-mongoose/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-mongoose/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('mongoose instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -21,12 +20,11 @@ Deno.test('mongoose instrumentation: included in default integrations (Deno 2.8. Deno.test('mongoose instrumentation: orchestrion:mongoose:model_save channel produces a nested db span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:mongoose:model_save'); @@ -51,21 +49,27 @@ Deno.test('mongoose instrumentation: orchestrion:mongoose:model_save channel pro }); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const mongooseSpan = parent.spans?.find(s => s.op === 'db'); - assertExists(mongooseSpan, `expected a db child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(mongooseSpan!.description, 'mongoose.BlogPost.save'); - assertEquals(mongooseSpan!.data?.['db.system.name'], 'mongodb'); - assertEquals(mongooseSpan!.data?.['db.namespace'], 'mydb'); - assertEquals(mongooseSpan!.data?.['db.collection.name'], 'blogposts'); - assertEquals(mongooseSpan!.data?.['db.operation.name'], 'save'); - assertEquals(mongooseSpan!.data?.['db.user'], 'root'); - assertEquals(mongooseSpan!.data?.['server.address'], '127.0.0.1'); - assertEquals(mongooseSpan!.data?.['server.port'], 27017); - assertEquals(mongooseSpan!.data?.['sentry.origin'], 'auto.db.mongoose'); + const mongooseSpan = children.find(s => getSpanOp(s) === 'db'); + assertExists(mongooseSpan, `expected a db child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`); + assertEquals(mongooseSpan.name, 'save blogposts'); + assertEquals(mongooseSpan.attributes['db.system.name']?.value, 'mongodb'); + assertEquals(mongooseSpan.attributes['db.namespace']?.value, 'mydb'); + assertEquals(mongooseSpan.attributes['db.collection.name']?.value, 'blogposts'); + assertEquals(mongooseSpan.attributes['db.operation.name']?.value, 'save'); + assertEquals(mongooseSpan.attributes['db.user']?.value, 'root'); + assertEquals(mongooseSpan.attributes['server.address']?.value, '127.0.0.1'); + assertEquals(mongooseSpan.attributes['server.port']?.value, 27017); + assertEquals(mongooseSpan.attributes['sentry.origin']?.value, 'auto.db.mongoose'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-mysql/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-mysql/test.ts index c263bdace328..6b46f548debb 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-mysql/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-mysql/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('mysql instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -54,12 +53,11 @@ Deno.test('@sentry/deno/import: transforms mysql so it publishes the orchestrion Deno.test('mysql instrumentation: orchestrion:mysql:query channel produces a nested db span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:mysql:query'); @@ -84,19 +82,25 @@ Deno.test('mysql instrumentation: orchestrion:mysql:query channel produces a nes }); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const mysqlSpan = parent.spans?.find(s => s.op === 'db'); - assertExists(mysqlSpan, `expected a db child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(mysqlSpan!.description, 'SELECT ? AS solution'); - assertEquals(mysqlSpan!.data?.['db.system.name'], 'mysql'); - assertEquals(mysqlSpan!.data?.['db.query.text'], 'SELECT ? AS solution'); - assertEquals(mysqlSpan!.data?.['server.address'], '127.0.0.1'); - assertEquals(mysqlSpan!.data?.['server.port'], 3306); - assertEquals(mysqlSpan!.data?.['db.user'], 'root'); - assertEquals(mysqlSpan!.data?.['sentry.origin'], 'auto.db.mysql'); + const mysqlSpan = children.find(s => getSpanOp(s) === 'db'); + assertExists(mysqlSpan, `expected a db child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`); + assertEquals(mysqlSpan.name, 'SELECT'); + assertEquals(mysqlSpan.attributes['db.system.name']?.value, 'mysql'); + assertEquals(mysqlSpan.attributes['db.query.text']?.value, 'SELECT ? AS solution'); + assertEquals(mysqlSpan.attributes['server.address']?.value, '127.0.0.1'); + assertEquals(mysqlSpan.attributes['server.port']?.value, 3306); + assertEquals(mysqlSpan.attributes['db.user']?.value, 'root'); + assertEquals(mysqlSpan.attributes['sentry.origin']?.value, 'auto.db.mysql'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-mysql2/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-mysql2/test.ts index 1c8efe1364e9..85a992fe89ab 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-mysql2/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-mysql2/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('mysql2 instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -21,12 +20,11 @@ Deno.test('mysql2 instrumentation: included in default integrations (Deno 2.8.0+ Deno.test('mysql2 instrumentation: orchestrion:mysql2:query channel produces a nested db span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:mysql2:query'); @@ -46,20 +44,26 @@ Deno.test('mysql2 instrumentation: orchestrion:mysql2:query channel produces a n }); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const mysqlSpan = parent.spans?.find(s => s.op === 'db'); - assertExists(mysqlSpan, `expected a db child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(mysqlSpan!.description, 'SELECT ? AS solution'); - assertEquals(mysqlSpan!.data?.['db.system.name'], 'mysql'); - assertEquals(mysqlSpan!.data?.['db.query.text'], 'SELECT ? AS solution'); - assertEquals(mysqlSpan!.data?.['db.namespace'], 'mydb'); - assertEquals(mysqlSpan!.data?.['db.user'], 'root'); - assertEquals(mysqlSpan!.data?.['server.address'], '127.0.0.1'); - assertEquals(mysqlSpan!.data?.['server.port'], 3306); - assertEquals(mysqlSpan!.data?.['sentry.origin'], 'auto.db.mysql2'); + const mysqlSpan = children.find(s => getSpanOp(s) === 'db'); + assertExists(mysqlSpan, `expected a db child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`); + assertEquals(mysqlSpan.name, 'SELECT'); + assertEquals(mysqlSpan.attributes['db.system.name']?.value, 'mysql'); + assertEquals(mysqlSpan.attributes['db.query.text']?.value, 'SELECT ? AS solution'); + assertEquals(mysqlSpan.attributes['db.namespace']?.value, 'mydb'); + assertEquals(mysqlSpan.attributes['db.user']?.value, 'root'); + assertEquals(mysqlSpan.attributes['server.address']?.value, '127.0.0.1'); + assertEquals(mysqlSpan.attributes['server.port']?.value, 3306); + assertEquals(mysqlSpan.attributes['sentry.origin']?.value, 'auto.db.mysql2'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-openai/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-openai/test.ts index 2d1c47b2e30b..98a47dcdf06d 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-openai/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-openai/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('openai instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -21,12 +20,11 @@ Deno.test('openai instrumentation: included in default integrations (Deno 2.8.0+ Deno.test('openai instrumentation: orchestrion:openai:chat channel produces a nested gen_ai span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:openai:chat'); @@ -46,19 +44,25 @@ Deno.test('openai instrumentation: orchestrion:openai:chat channel produces a ne channel.asyncEnd.publish(ctx); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const aiSpan = parent.spans?.find(s => s.op === 'gen_ai.chat'); - assertExists(aiSpan, `expected a gen_ai.chat child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(aiSpan!.description, 'chat gpt-4o'); - assertEquals(aiSpan!.data?.['gen_ai.provider.name'], 'openai'); - assertEquals(aiSpan!.data?.['gen_ai.operation.name'], 'chat'); - assertEquals(aiSpan!.data?.['gen_ai.request.model'], 'gpt-4o'); - assertEquals(aiSpan!.data?.['gen_ai.response.model'], 'gpt-4o-2024-08-06'); - assertEquals(aiSpan!.data?.['gen_ai.usage.total_tokens'], 15); - assertEquals(aiSpan!.data?.['sentry.origin'], 'auto.ai.openai'); + const aiSpan = children.find(s => getSpanOp(s) === 'gen_ai.chat'); + assertExists(aiSpan, `expected a gen_ai.chat child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`); + assertEquals(aiSpan.name, 'chat gpt-4o'); + assertEquals(aiSpan.attributes['gen_ai.provider.name']?.value, 'openai'); + assertEquals(aiSpan.attributes['gen_ai.operation.name']?.value, 'chat'); + assertEquals(aiSpan.attributes['gen_ai.request.model']?.value, 'gpt-4o'); + assertEquals(aiSpan.attributes['gen_ai.response.model']?.value, 'gpt-4o-2024-08-06'); + assertEquals(aiSpan.attributes['gen_ai.usage.total_tokens']?.value, 15); + assertEquals(aiSpan.attributes['sentry.origin']?.value, 'auto.ai.openai'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-postgres/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-postgres/test.ts index 81012e6fb979..26781f3084c5 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-postgres/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-postgres/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('pg instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -54,12 +53,11 @@ Deno.test('@sentry/deno/import: transforms pg so it publishes the orchestrion ch Deno.test('pg instrumentation: orchestrion:pg:query channel produces a nested db span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:pg:query'); @@ -84,19 +82,25 @@ Deno.test('pg instrumentation: orchestrion:pg:query channel produces a nested db }); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const pgSpan = parent.spans?.find(s => s.op === 'db'); - assertExists(pgSpan, `expected a db child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(pgSpan!.description, 'SELECT ? AS solution'); - assertEquals(pgSpan!.data?.['db.system.name'], 'postgresql'); - assertEquals(pgSpan!.data?.['db.query.text'], 'SELECT ? AS solution'); - assertEquals(pgSpan!.data?.['server.address'], '127.0.0.1'); - assertEquals(pgSpan!.data?.['server.port'], 5432); - assertEquals(pgSpan!.data?.['db.user'], 'root'); - assertEquals(pgSpan!.data?.['sentry.origin'], 'auto.db.postgres'); + const pgSpan = children.find(s => getSpanOp(s) === 'db'); + assertExists(pgSpan, `expected a db child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`); + assertEquals(pgSpan.name, 'SELECT'); + assertEquals(pgSpan.attributes['db.system.name']?.value, 'postgresql'); + assertEquals(pgSpan.attributes['db.query.text']?.value, 'SELECT ? AS solution'); + assertEquals(pgSpan.attributes['server.address']?.value, '127.0.0.1'); + assertEquals(pgSpan.attributes['server.port']?.value, 5432); + assertEquals(pgSpan.attributes['db.user']?.value, 'root'); + assertEquals(pgSpan.attributes['sentry.origin']?.value, 'auto.db.postgres'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-postgresjs/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-postgresjs/test.ts index 640e51733173..7e3454f2f019 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-postgresjs/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-postgresjs/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('postgres.js instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -21,12 +20,11 @@ Deno.test('postgres.js instrumentation: included in default integrations (Deno 2 Deno.test('postgres.js instrumentation: orchestrion:postgres:handle channel produces a nested db span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:postgres:handle'); @@ -48,18 +46,24 @@ Deno.test('postgres.js instrumentation: orchestrion:postgres:handle channel prod query.resolve({ command: 'SELECT' }); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const pgSpan = parent.spans?.find(s => s.op === 'db'); - assertExists(pgSpan, `expected a db child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(pgSpan!.description, 'SELECT name FROM users'); - assertEquals(pgSpan!.data?.['db.system.name'], 'postgres'); - assertEquals(pgSpan!.data?.['db.query.text'], 'SELECT name FROM users'); + const pgSpan = children.find(s => getSpanOp(s) === 'db'); + assertExists(pgSpan, `expected a db child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`); + assertEquals(pgSpan.name, 'SELECT users'); + assertEquals(pgSpan.attributes['db.system.name']?.value, 'postgres'); + assertEquals(pgSpan.attributes['db.query.text']?.value, 'SELECT name FROM users'); // Set by the resolve wrapper from the `command` passed to `query.resolve`. - assertEquals(pgSpan!.data?.['db.operation.name'], 'SELECT'); - assertEquals(pgSpan!.data?.['sentry.origin'], 'auto.db.postgresjs'); + assertEquals(pgSpan.attributes['db.operation.name']?.value, 'SELECT'); + assertEquals(pgSpan.attributes['sentry.origin']?.value, 'auto.db.postgresjs'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-tedious/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-tedious/test.ts index 7e9f2d6ec34c..e904576b8248 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-tedious/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-tedious/test.ts @@ -3,16 +3,15 @@ import { EventEmitter } from 'node:events'; import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('tedious instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -22,12 +21,11 @@ Deno.test('tedious instrumentation: included in default integrations (Deno 2.8.0 Deno.test('tedious instrumentation: orchestrion:tedious:execSql channel produces a nested db span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); // The connection and request are `EventEmitter`s; the subscriber only traces @@ -49,20 +47,26 @@ Deno.test('tedious instrumentation: orchestrion:tedious:execSql channel produces request.callback(); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const tediousSpan = parent.spans?.find(s => s.op === 'db'); - assertExists(tediousSpan, `expected a db child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(tediousSpan!.description, 'SELECT ?'); - assertEquals(tediousSpan!.data?.['db.system.name'], 'mssql'); - assertEquals(tediousSpan!.data?.['db.namespace'], 'mydb'); - assertEquals(tediousSpan!.data?.['db.user'], 'sa'); - assertEquals(tediousSpan!.data?.['db.query.text'], 'SELECT ?'); - assertEquals(tediousSpan!.data?.['server.address'], '127.0.0.1'); - assertEquals(tediousSpan!.data?.['server.port'], 1433); - assertEquals(tediousSpan!.data?.['sentry.origin'], 'auto.db.tedious'); + const tediousSpan = children.find(s => getSpanOp(s) === 'db'); + assertExists(tediousSpan, `expected a db child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`); + assertEquals(tediousSpan.name, 'SELECT'); + assertEquals(tediousSpan.attributes['db.system.name']?.value, 'mssql'); + assertEquals(tediousSpan.attributes['db.namespace']?.value, 'mydb'); + assertEquals(tediousSpan.attributes['db.user']?.value, 'sa'); + assertEquals(tediousSpan.attributes['db.query.text']?.value, 'SELECT ?'); + assertEquals(tediousSpan.attributes['server.address']?.value, '127.0.0.1'); + assertEquals(tediousSpan.attributes['server.port']?.value, 1433); + assertEquals(tediousSpan.attributes['sentry.origin']?.value, 'auto.db.tedious'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-vercel-ai/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-vercel-ai/test.ts index 8c7cc42b5fc2..fdd20fd3239d 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-vercel-ai/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-vercel-ai/test.ts @@ -2,18 +2,17 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('vercel-ai instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ dsn: 'https://username@domain/123', tracesSampleRate: 1, - traceLifecycle: 'static', }) as DenoClient; const names = client.getOptions().integrations.map(i => i.name); assert(names.includes('VercelAI'), `VercelAI should be in defaults, got ${names.join(', ')}`); @@ -21,12 +20,11 @@ Deno.test('vercel-ai instrumentation: included in default integrations (Deno 2.8 Deno.test('vercel-ai instrumentation: orchestrion:ai:generateText channel produces a nested invoke_agent span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:ai:generateText'); @@ -45,22 +43,28 @@ Deno.test('vercel-ai instrumentation: orchestrion:ai:generateText channel produc channel.asyncEnd.publish(ctx); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const aiSpan = parent.spans?.find(s => s.op === 'gen_ai.invoke_agent'); + const aiSpan = children.find(s => getSpanOp(s) === 'gen_ai.invoke_agent'); assertExists( aiSpan, - `expected a gen_ai.invoke_agent child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`, + `expected a gen_ai.invoke_agent child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`, ); - assertEquals(aiSpan!.description, 'invoke_agent'); - assertEquals(aiSpan!.data?.['gen_ai.provider.name'], 'openai'); - assertEquals(aiSpan!.data?.['gen_ai.operation.name'], 'invoke_agent'); - assertEquals(aiSpan!.data?.['gen_ai.request.model'], 'gpt-4o'); - assertEquals(aiSpan!.data?.['vercel.ai.operationId'], 'ai.generateText'); - assertEquals(aiSpan!.data?.['gen_ai.usage.total_tokens'], 15); - assertEquals(aiSpan!.data?.['sentry.origin'], 'auto.vercelai.channel'); + assertEquals(aiSpan.name, 'invoke_agent'); + assertEquals(aiSpan.attributes['gen_ai.provider.name']?.value, 'openai'); + assertEquals(aiSpan.attributes['gen_ai.operation.name']?.value, 'invoke_agent'); + assertEquals(aiSpan.attributes['gen_ai.request.model']?.value, 'gpt-4o'); + assertEquals(aiSpan.attributes['vercel.ai.operationId']?.value, 'ai.generateText'); + assertEquals(aiSpan.attributes['gen_ai.usage.total_tokens']?.value, 15); + assertEquals(aiSpan.attributes['sentry.origin']?.value, 'auto.vercelai.channel'); }); diff --git a/dev-packages/e2e-tests/README.md b/dev-packages/e2e-tests/README.md index 4a398a2b9002..cb2964332492 100644 --- a/dev-packages/e2e-tests/README.md +++ b/dev-packages/e2e-tests/README.md @@ -116,6 +116,24 @@ Make sure to add a `test:build` and `test:assert` command to the new app's `pack Sentry packages are automatically resolved to the local build via pnpm overrides injected at test time, so no manual registry configuration is needed. +### Test apps that deploy a real Cloudflare Worker + +Apps like `cloudflare-workers-send-to-sentry` deploy the built app as a real Worker and check what arrives in Sentry: + +- Use `createWorkerGlobalSetup` and `workerGlobalTeardown` from `@sentry-internal/test-utils/cloudflare` as the + Playwright global setup and teardown. Tests read the Worker URL from `E2E_TEST_WORKER_URL`, send requests with + `fetchFromWorker`, and poll Sentry with the helpers from `@sentry-internal/test-utils/cli` (the app needs `sentry` as + a dev dependency). +- The Worker gets `E2E_TEST_DSN`, `E2E_TEST_WORKER_TOKEN` and the values of the `secrets` option as secrets. +- CI keeps the Worker after the run, and anyone can reach its `workers.dev` URL. If the Worker can spend money (for + example, it holds an LLM API key), it must reject requests without `Authorization: Bearer `. + The token is new for every deploy, and `fetchFromWorker` sends it. See + `cloudflare-flue-send-to-sentry/src/auth.ts`. +- Add the `workerPrefix` to `.github/workflows/cleanup-e2e-workers.yml`, which deletes the Worker of a PR when the PR + closes. +- Set `"sentryTest": { "optional": true }` in `package.json`, because only the optional CI job has the Cloudflare + secrets. + ## Troubleshooting ### Common Issues diff --git a/dev-packages/e2e-tests/test-applications/astro-5/src/pages/route-provider/[id].astro b/dev-packages/e2e-tests/test-applications/astro-5/src/pages/route-provider/[id].astro new file mode 100644 index 000000000000..ff0ee0f13d59 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5/src/pages/route-provider/[id].astro @@ -0,0 +1,18 @@ +--- +import Layout from '../../layouts/Layout.astro'; + +export const prerender = false; +--- + + + +
+
+ + diff --git a/dev-packages/e2e-tests/test-applications/astro-5/tests/route-provider.test.ts b/dev-packages/e2e-tests/test-applications/astro-5/tests/route-provider.test.ts new file mode 100644 index 000000000000..09e444a04c26 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5/tests/route-provider.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from '@playwright/test'; + +// The route provider reads the route the middleware renders into the page, so this fails if that route +// never reaches the browser. +test('resolves the parameterized route through the route provider', async ({ page }) => { + await page.goto('/route-provider/123'); + await page.locator('#resolve-route').click(); + + await expect(page.locator('#resolved-route')).toHaveText('/route-provider/[id]'); +}); diff --git a/dev-packages/e2e-tests/test-applications/astro-7-static/package.json b/dev-packages/e2e-tests/test-applications/astro-7-static/package.json index 35c051bee12d..ce0452bf7e37 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7-static/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-7-static/package.json @@ -19,7 +19,7 @@ "@playwright/test": "~1.63.0", "@sentry-internal/test-utils": "link:../../../test-utils", "@sentry/astro": "file:../../packed/sentry-astro-packed.tgz", - "astro": "^7.2.4", + "astro": "^7.2.8", "ioredis": "5.10.1", "mysql": "^2.18.1" }, diff --git a/dev-packages/e2e-tests/test-applications/astro-7/package.json b/dev-packages/e2e-tests/test-applications/astro-7/package.json index acd09a130916..33126242d1f6 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-7/package.json @@ -19,7 +19,7 @@ "@playwright/test": "~1.63.0", "@sentry-internal/test-utils": "link:../../../test-utils", "@sentry/astro": "file:../../packed/sentry-astro-packed.tgz", - "astro": "^7.2.4", + "astro": "^7.2.8", "ioredis": "5.10.1", "mysql": "^2.18.1" }, diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts b/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts index 31e6c83eabbe..2a157ff659c6 100644 --- a/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts @@ -277,6 +277,33 @@ test('a hit carries the parameterized route that was on the scope before the fre expect(attr(hit, 'sentry.segment.name')).toBe('/users/:id'); }); +// A registered route provider is preferred over the scope, which is what lets a framework SDK name the +// segment even when nothing stamped a route on the scope. The scope holds a different name here to prove it. +test('a hit carries the route resolved by a registered route provider', async ({ page }) => { + const hitPromise = waitForMetric(PROXY_SERVER_NAME, metric => isNavigation(metric, 'hit')); + + await page.goto('/'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 1'); + + await page.evaluate(() => { + const { Sentry } = window as unknown as { Sentry: typeof import('@sentry/browser') }; + Sentry.getCurrentScope().setTransactionName('/from-scope'); + Sentry.setRouteProvider({ resolveRoute: () => '/users/:id', resolveCurrentRoute: () => '/users/:id' }); + }); + + await page.click('#to-page-2'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 2'); + await page.waitForTimeout(500); + + await page.evaluate(() => history.back()); + await page.waitForFunction(() => (window as unknown as { __bfcacheRestored?: boolean }).__bfcacheRestored === true, { + timeout: 5000, + }); + + const hit = await hitPromise; + expect(attr(hit, 'sentry.segment.name')).toBe('/users/:id'); +}); + // Without a routing integration the scope has no transaction name, so the segment name falls back to // `location.pathname` (page 1 is served at '/'). This matches how browserTracing names an unrouted pageload. test('a hit falls back to the raw pathname when no route is on the scope', async ({ page }) => { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/.gitignore new file mode 100644 index 000000000000..b6c344e957e1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/.gitignore @@ -0,0 +1,5 @@ +dist +.wrangler +node_modules +test-results +pnpm-lock.yaml diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/flue.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/flue.config.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/cloudflare-flue/flue.config.ts rename to dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/flue.config.ts diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/global-setup.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/global-setup.ts new file mode 100644 index 000000000000..631329e9f0e3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/global-setup.ts @@ -0,0 +1,6 @@ +import { createWorkerGlobalSetup } from '@sentry-internal/test-utils/cloudflare'; + +export default createWorkerGlobalSetup({ + workerPrefix: 'e2e-flue-send-to-sentry', + secrets: { OPENROUTER_API_KEY: 'E2E_OPENROUTER_API_KEY' }, +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/global-teardown.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/global-teardown.ts new file mode 100644 index 000000000000..c7bcfe696e66 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/global-teardown.ts @@ -0,0 +1 @@ +export { workerGlobalTeardown as default } from '@sentry-internal/test-utils/cloudflare'; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/package.json similarity index 64% rename from dev-packages/e2e-tests/test-applications/cloudflare-flue/package.json rename to dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/package.json index ec82ef912a33..7f7be894c7e6 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/package.json @@ -1,16 +1,15 @@ { - "name": "cloudflare-flue", + "name": "cloudflare-flue-send-to-sentry", "version": "0.0.0", "private": true, "type": "module", "scripts": { "build": "vite build", - "preview": "vite preview --port 4112", - "typecheck": "tsc --noEmit", - "clean": "npx rimraf node_modules dist .wrangler pnpm-lock.yaml", - "test:build": "pnpm install && OPENROUTER_API_KEY=$E2E_OPENROUTER_API_KEY pnpm build", - "test:assert": "pnpm test:prod", - "test:prod": "TEST_ENV=production OPENROUTER_API_KEY=$E2E_OPENROUTER_API_KEY playwright test" + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml dist .wrangler", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm typecheck && pnpm test" }, "dependencies": { "@flue/runtime": "2.0.5", @@ -27,9 +26,11 @@ "@flue/vite": "2.0.5", "@playwright/test": "~1.63.0", "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^24.0.0", + "sentry": "~0.44.1", "typescript": "^5.5.2", "vite": "7.3.5", - "wrangler": "^4.86.0" + "wrangler": "^4.114.0" }, "volta": { "node": "24.15.0", diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/playwright.config.ts new file mode 100644 index 000000000000..cfb1b52d440e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/playwright.config.ts @@ -0,0 +1,16 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig(undefined, { + // The tests read what arrived in Sentry through the sentry CLI, so there is no event proxy to start. + webServer: undefined, + // The worker is deployed once for the whole run and deleted again afterwards. + globalSetup: './global-setup.ts', + globalTeardown: './global-teardown.ts', + // Each test drives a real OpenRouter turn and then waits ~2min until its spans are queryable. + timeout: 300_000, + // Every test spends most of its time polling Sentry, so run them all at once. + fullyParallel: true, + workers: '100%', +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/src/agents/hello.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/agents/hello.ts similarity index 95% rename from dev-packages/e2e-tests/test-applications/cloudflare-flue/src/agents/hello.ts rename to dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/agents/hello.ts index df0589a1988e..4671208793f3 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue/src/agents/hello.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/agents/hello.ts @@ -17,9 +17,9 @@ export function Hello() { input: v.object({ city: v.string() }), // Wrapped in a manual span: Flue runs the tool while the SDK's `execute_tool` span is active, // so this should nest directly under it rather than landing beside it. - run: ({ city }) => + run: ({ data }) => Sentry.startSpan({ name: 'resolve-weather', attributes: { 'weather.source': 'static-table' } }, () => { - return `It is 21 degrees and sunny in ${city}.`; + return `It is 21 degrees and sunny in ${data.city}.`; }), }); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/src/app.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/app.ts similarity index 53% rename from dev-packages/e2e-tests/test-applications/cloudflare-flue/src/app.ts rename to dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/app.ts index 613e9453c506..974c275b185e 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/app.ts @@ -1,9 +1,13 @@ import { createAgentRouter } from '@flue/runtime/routing'; import { Hono } from 'hono'; import { Hello } from './agents/hello.ts'; +import { requireTestToken } from './auth.ts'; const app = new Hono(); +// global-setup.ts waits for this route to answer before the tests start. +app.get('/', c => c.text('Hello World!')); +app.use('/agents/*', requireTestToken); app.route('/agents/hello', createAgentRouter(Hello)); export default app; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/auth.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/auth.ts new file mode 100644 index 000000000000..a2c81dd494f1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/auth.ts @@ -0,0 +1,14 @@ +import { createMiddleware } from 'hono/factory'; + +/** + * CI keeps the Worker after the run, and it holds an OpenRouter key. So the agent routes only answer + * the test run that deployed the Worker. `createWorkerGlobalSetup` makes the token for each deploy, and + * `fetchFromWorker` sends it. + */ +export const requireTestToken = createMiddleware<{ Bindings: Env }>(async (c, next) => { + const token = c.env.E2E_TEST_WORKER_TOKEN; + if (!token || c.req.header('Authorization') !== `Bearer ${token}`) { + return c.text('Unauthorized', 401); + } + await next(); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/src/cloudflare.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/cloudflare.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/cloudflare-flue/src/cloudflare.ts rename to dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/cloudflare.ts diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/src/env.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/env.d.ts similarity index 68% rename from dev-packages/e2e-tests/test-applications/cloudflare-flue/src/env.d.ts rename to dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/env.d.ts index ecd15b570543..2bbac1d75aa7 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue/src/env.d.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/env.d.ts @@ -1,4 +1,5 @@ interface Env { E2E_TEST_DSN: string; OPENROUTER_API_KEY: string; + E2E_TEST_WORKER_TOKEN: string; } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/src/loaders.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/loaders.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/cloudflare-flue/src/loaders.ts rename to dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/loaders.ts diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/src/sentry.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/sentry.ts similarity index 84% rename from dev-packages/e2e-tests/test-applications/cloudflare-flue/src/sentry.ts rename to dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/sentry.ts index 5cbc2856bfff..c983d58861ab 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue/src/sentry.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/sentry.ts @@ -3,7 +3,7 @@ import * as Sentry from '@sentry/cloudflare'; // Each Flue agent runs in its own Durable Object, so the DO class is what has to be wrapped for // `Sentry.init` to run and spans to be flushed. The agent module re-exports this as `cloudflare`, -// which is how Flue picks it up — defining it here alone does nothing. +// which is how Flue picks it up, defining it here alone does nothing. // // There is deliberately no `instrument()` call in this app: registering the Flue instrumentation is // what `@sentry/cloudflare/vite` does at build time, and these tests exist to prove it. @@ -13,7 +13,6 @@ export const cloudflare = extend({ (env: Env) => ({ dsn: env.E2E_TEST_DSN, environment: 'qa', - tunnel: 'http://localhost:3031/', // proxy server tracesSampleRate: 1.0, }), Final, diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/dataloader.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/dataloader.test.ts new file mode 100644 index 000000000000..defcd51510d4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/dataloader.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from '@playwright/test'; +import type { TraceItem } from '@sentry-internal/test-utils/cli'; +import { + EVENT_POLLING_OPTIONS, + fetchSpanAttributes, + fetchTrace, + findTraceIdOfSpan, + flattenTrace, + traceTarget, +} from '@sentry-internal/test-utils/cli'; +import { newInstanceId, runAgentTurn } from './utils'; + +// Set by global-setup.ts once the worker for this run is deployed. +const workerUrl = process.env.E2E_TEST_WORKER_URL!; + +/** + * On Cloudflare orchestrion runs at build time (`@sentry/cloudflare/vite` injects the channels), + * so unlike the Node app there is no `--import` bootstrap and no variant: the span is either there + * or the build-time instrumentation regressed. + */ +test('captures orchestrion-instrumented dataloader spans in the same trace as the AI spans', async () => { + const conversationId = await runAgentTurn( + workerUrl, + newInstanceId('dataloader'), + 'Please call count_items to count the items.', + ); + + let traceId: string | undefined; + await expect + .poll(() => (traceId = findTraceIdOfSpan(`gen_ai.conversation.id:${conversationId}`)), EVENT_POLLING_OPTIONS) + .toBeDefined(); + + console.log(`Polling for the dataloader span: sentry trace view ${traceTarget(traceId!)}`); + + let spans: TraceItem[] = []; + await expect + .poll(() => (spans = flattenTrace(fetchTrace(traceId!))).map(span => span.op), EVENT_POLLING_OPTIONS) + .toEqual(expect.arrayContaining(['gen_ai.execute_tool', 'cache.get'])); + + const dataloaderSpan = spans.find(span => span.op === 'cache.get'); + const toolSpan = spans.find(span => span.description === 'execute_tool count_items'); + + await expect + .poll(() => fetchSpanAttributes(traceId!, dataloaderSpan!.event_id!), EVENT_POLLING_OPTIONS) + .toMatchObject({ origin: 'auto.db.dataloader' }); + expect(toolSpan).toBeDefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/errors.test.ts new file mode 100644 index 000000000000..6b7258deed4d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/errors.test.ts @@ -0,0 +1,52 @@ +import { expect, test } from '@playwright/test'; +import type { TraceItem } from '@sentry-internal/test-utils/cli'; +import { + EVENT_POLLING_OPTIONS, + fetchEvent, + fetchSpanAttributes, + fetchTrace, + findErrorInTrace, + findTraceIdOfSpan, + flattenTrace, + traceTarget, +} from '@sentry-internal/test-utils/cli'; +import { newInstanceId, runAgentTurn } from './utils'; + +// Set by global-setup.ts once the worker for this run is deployed. +const workerUrl = process.env.E2E_TEST_WORKER_URL!; + +test('captures an error thrown inside a Flue tool and marks its span errored', async () => { + const conversationId = await runAgentTurn( + workerUrl, + newInstanceId('failure'), + 'Please call fail_now to trigger a failure.', + ); + + let traceId: string | undefined; + await expect + .poll(() => (traceId = findTraceIdOfSpan(`gen_ai.conversation.id:${conversationId}`)), EVENT_POLLING_OPTIONS) + .toBeDefined(); + + console.log(`Polling for the tool error: sentry trace view ${traceTarget(traceId!)}`); + + 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]?.mechanism + ?.type, + EVENT_POLLING_OPTIONS, + ) + .toBe('auto.ai.flue'); + + let spans: TraceItem[] = []; + await expect + .poll(() => (spans = flattenTrace(fetchTrace(traceId!))).map(span => span.description), EVENT_POLLING_OPTIONS) + .toContain('execute_tool fail_now'); + + const executeTool = spans.find(span => span.description === 'execute_tool fail_now'); + await expect + .poll(() => fetchSpanAttributes(traceId!, executeTool!.event_id!), EVENT_POLLING_OPTIONS) + .toMatchObject({ 'gen_ai.tool.name': 'fail_now', 'span.status': 'error' }); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/flue.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/flue.test.ts new file mode 100644 index 000000000000..ac6c0dab180f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/flue.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from '@playwright/test'; +import type { TraceItem } from '@sentry-internal/test-utils/cli'; +import { + EVENT_POLLING_OPTIONS, + fetchSpanAttributes, + fetchTrace, + findTraceIdOfSpan, + flattenTrace, + traceTarget, +} from '@sentry-internal/test-utils/cli'; +import { newInstanceId, runAgentTurn } from './utils'; + +// Set by global-setup.ts once the worker for this run is deployed. +const workerUrl = process.env.E2E_TEST_WORKER_URL!; + +/** + * This app never calls `instrument()`. On Cloudflare the registration comes from the build: + * `@sentry/cloudflare/vite` provides the `@flue/runtime` binding and the orchestrion registration + * installs `flueIntegration()`. So any `gen_ai` span here is itself the proof that the auto-wiring + * worked, and a manual-registration regression shows up as an empty trace, not a wrong attribute. + */ +test('instruments a Flue agent with no manual instrument() call', async () => { + const conversationId = await runAgentTurn(workerUrl, newInstanceId('weather'), 'What is the weather in Paris?'); + + let traceId: string | undefined; + await expect + .poll(() => (traceId = findTraceIdOfSpan(`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(() => (spans = flattenTrace(fetchTrace(traceId!))).map(span => span.op), EVENT_POLLING_OPTIONS) + .toEqual(expect.arrayContaining(['gen_ai.invoke_agent', 'gen_ai.chat', 'gen_ai.execute_tool'])); + + const invokeAgent = spans.find(span => span.op === 'gen_ai.invoke_agent'); + const chat = spans.find(span => span.op === 'gen_ai.chat'); + const executeTool = spans.find(span => span.op === 'gen_ai.execute_tool'); + + await expect + .poll(() => fetchSpanAttributes(traceId!, invokeAgent!.event_id!), EVENT_POLLING_OPTIONS) + .toMatchObject({ + origin: 'auto.ai.flue', + 'gen_ai.agent.name': 'Hello', + }); + + await expect + .poll(() => fetchSpanAttributes(traceId!, chat!.event_id!), EVENT_POLLING_OPTIONS) + .toMatchObject({ + origin: 'auto.ai.flue', + 'gen_ai.provider.name': 'openrouter', + 'gen_ai.usage.input_tokens': expect.anything(), + 'gen_ai.cost.total_tokens': expect.anything(), + }); + + await expect + .poll(() => fetchSpanAttributes(traceId!, executeTool!.event_id!), EVENT_POLLING_OPTIONS) + .toMatchObject({ 'gen_ai.tool.name': 'get_weather' }); + expect(chat?.parent_span_id).toBe(invokeAgent?.event_id); + expect(executeTool?.parent_span_id).toBe(invokeAgent?.event_id); +}); + +test('nests a manual span raised inside a tool under that tool span', async () => { + const conversationId = await runAgentTurn(workerUrl, newInstanceId('manual-span'), 'What is the weather in Berlin?'); + + let traceId: string | undefined; + await expect + .poll(() => (traceId = findTraceIdOfSpan(`gen_ai.conversation.id:${conversationId}`)), EVENT_POLLING_OPTIONS) + .toBeDefined(); + + console.log(`Polling for the manual span: sentry trace view ${traceTarget(traceId!)}`); + + let spans: TraceItem[] = []; + await expect + .poll(() => (spans = flattenTrace(fetchTrace(traceId!))).map(span => span.description), EVENT_POLLING_OPTIONS) + .toEqual(expect.arrayContaining(['execute_tool get_weather', 'resolve-weather'])); + + const executeTool = spans.find(span => span.op === 'gen_ai.execute_tool'); + const manualSpan = spans.find(span => span.description === 'resolve-weather'); + + await expect + .poll(() => fetchSpanAttributes(traceId!, manualSpan!.event_id!), EVENT_POLLING_OPTIONS) + .toMatchObject({ 'weather.source': 'static-table' }); + expect(manualSpan?.parent_span_id).toBe(executeTool?.event_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/utils.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/utils.ts new file mode 100644 index 000000000000..1730d7781025 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/utils.ts @@ -0,0 +1,38 @@ +import { fetchFromWorker } from '@sentry-internal/test-utils/cloudflare'; + +/** An agent instance id nothing has used yet, so a settled record cannot end the wait early. */ +export function newInstanceId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** + * Run one agent turn and wait for it to settle. Returns the id of the conversation, which the SDK + * sets as `gen_ai.conversation.id` on the agent span. Flue runs the turn from a Durable Object alarm, + * and the SDK starts a new trace for every alarm, so the tests find the trace by this id. + * + * `POST /:id` only admits the work (it returns `202` and the turn runs after), so this reads the + * conversation back until it reports a settlement. + */ +export async function runAgentTurn(workerUrl: string, instanceId: string, message: string): Promise { + const url = `${workerUrl}/agents/hello/${instanceId}`; + + await fetchFromWorker(url, 202, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ kind: 'user', body: message }), + }); + + const deadline = Date.now() + 90_000; + while (Date.now() < deadline) { + const conversation = JSON.parse(await fetchFromWorker(url, 200)) 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`); +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tsconfig.json similarity index 92% rename from dev-packages/e2e-tests/test-applications/cloudflare-flue/tsconfig.json rename to dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tsconfig.json index c612b8a5194a..83c877306e74 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tsconfig.json @@ -4,6 +4,7 @@ "lib": ["es2021"], "module": "es2022", "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, "resolveJsonModule": true, "allowJs": true, "checkJs": false, diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tsconfig.node.json b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tsconfig.node.json new file mode 100644 index 000000000000..cd031756584c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tsconfig.node.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "exclude": [], + "include": ["tests/**/*", "global-setup.ts", "global-teardown.ts", "playwright.config.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/vite.config.mts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/vite.config.mts similarity index 100% rename from dev-packages/e2e-tests/test-applications/cloudflare-flue/vite.config.mts rename to dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/vite.config.mts diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/wrangler.jsonc new file mode 100644 index 000000000000..f3883b2d5ff9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "$schema": "./node_modules/wrangler/config-schema.json", + // Placeholder only: every test run deploys under a unique name, see global-setup.ts. + "name": "cloudflare-flue-send-to-sentry", + "compatibility_date": "2026-06-01", + "compatibility_flags": ["nodejs_compat"], + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["FlueHelloAgent"] }], + "workers_dev": true, + // Workers Logs keep the invocations of the last 7 days, so a failed CI run can still be inspected. + "observability": { "enabled": true }, + "secrets": { "required": ["E2E_TEST_DSN", "OPENROUTER_API_KEY", "E2E_TEST_WORKER_TOKEN"] }, +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue/playwright.config.ts deleted file mode 100644 index a11f1656965f..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue/playwright.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { getPlaywrightConfig } from '@sentry-internal/test-utils'; - -const testEnv = process.env.TEST_ENV; - -if (!testEnv) { - throw new Error('No test env defined'); -} - -const config = getPlaywrightConfig( - { startCommand: 'pnpm preview', port: 4112 }, - // Each test drives a real OpenRouter turn and then waits for the spans to flush, which does not - // fit the default 30s timeout when the provider is slow. - { timeout: 90_000 }, -); - -export default config; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-flue/start-event-proxy.mjs deleted file mode 100644 index a24f2d9a08a0..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue/start-event-proxy.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import { startEventProxyServer } from '@sentry-internal/test-utils'; - -startEventProxyServer({ - port: 3031, - proxyServerName: 'cloudflare-flue', -}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/tests/dataloader.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue/tests/dataloader.test.ts deleted file mode 100644 index 0193e0febbd0..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue/tests/dataloader.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; -import { newConversationId, runAgentTurn } from './utils'; - -const APP = 'cloudflare-flue'; - -const isDataloaderSpan = (span: { attributes?: Record }): boolean => - span.attributes?.['sentry.origin']?.value === 'auto.db.dataloader'; - -/** - * On Cloudflare orchestrion runs at build time — `@sentry/cloudflare/vite` injects the channels — - * so unlike the Node app there is no `--import` bootstrap and no variant: the span is either there - * or the build-time instrumentation regressed. - */ -test('captures orchestrion-instrumented dataloader spans in the same trace as the AI spans', async ({ baseURL }) => { - const spansPromise = collectStreamedSpans( - APP, - spansOfTrace => - spansOfTrace.some(span => span.attributes?.['gen_ai.tool.name']?.value === 'count_items') && - spansOfTrace.some(isDataloaderSpan), - ); - - await runAgentTurn(baseURL!, newConversationId('dataloader'), 'Please call count_items to count the items.'); - - const spans = await spansPromise; - const dataloaderSpan = spans.find(isDataloaderSpan); - const toolSpan = spans.find(span => span.attributes?.['gen_ai.tool.name']?.value === 'count_items'); - - expect(getSpanOp(dataloaderSpan!)).toBe('cache.get'); - expect(dataloaderSpan?.trace_id).toBe(toolSpan?.trace_id); -}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue/tests/errors.test.ts deleted file mode 100644 index 98a12ebc4c3a..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue/tests/errors.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { collectStreamedSpans, waitForError } from '@sentry-internal/test-utils'; -import { newConversationId, runAgentTurn } from './utils'; - -const APP = 'cloudflare-flue'; - -test('captures an error thrown inside a Flue tool and marks its span errored', async ({ baseURL }) => { - const errorPromise = waitForError( - APP, - event => event.exception?.values?.[0]?.value === 'Intentional flue tool failure', - ); - const spansPromise = collectStreamedSpans(APP, spansOfTrace => - spansOfTrace.some(span => span.attributes?.['gen_ai.tool.name']?.value === 'fail_now'), - ); - - await runAgentTurn(baseURL!, newConversationId('failure'), 'Please call fail_now to trigger a failure.'); - - const error = await errorPromise; - expect(error.exception?.values?.[0]?.mechanism?.type).toBe('auto.ai.flue'); - - const spans = await spansPromise; - const executeTool = spans.find(span => span.attributes?.['gen_ai.tool.name']?.value === 'fail_now'); - expect(executeTool?.status).toBe('error'); -}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/tests/flue.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue/tests/flue.test.ts deleted file mode 100644 index 41142178aebd..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue/tests/flue.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; -import { newConversationId, runAgentTurn } from './utils'; - -const APP = 'cloudflare-flue'; - -type SpanLike = { name?: string; attributes?: Record }; - -const usedTool = (toolName: string) => (spansOfTrace: SpanLike[]) => - spansOfTrace.some(span => span.attributes?.['gen_ai.tool.name']?.value === toolName); - -/** - * This app never calls `instrument()`. On Cloudflare the registration comes from the build: - * `@sentry/cloudflare/vite` provides the `@flue/runtime` binding and the orchestrion registration - * installs `flueIntegration()`. So any `gen_ai` span here is itself the proof that the auto-wiring - * worked — a manual-registration regression shows up as an empty trace, not a wrong attribute. - */ -test('instruments a Flue agent with no manual instrument() call', async ({ baseURL }) => { - const spansPromise = collectStreamedSpans( - APP, - spansOfTrace => - spansOfTrace.some(span => getSpanOp(span) === 'gen_ai.invoke_agent') && usedTool('get_weather')(spansOfTrace), - ); - - await runAgentTurn(baseURL!, newConversationId('weather'), 'What is the weather in Paris?'); - - const spans = await spansPromise; - const invokeAgent = spans.find(span => getSpanOp(span) === 'gen_ai.invoke_agent'); - const chat = spans.find(span => getSpanOp(span) === 'gen_ai.chat'); - const executeTool = spans.find(span => getSpanOp(span) === 'gen_ai.execute_tool'); - - expect(invokeAgent?.attributes?.['sentry.origin']?.value).toBe('auto.ai.flue'); - expect(invokeAgent?.attributes?.['gen_ai.agent.name']?.value).toBe('Hello'); - - expect(chat?.attributes?.['sentry.origin']?.value).toBe('auto.ai.flue'); - expect(chat?.attributes?.['gen_ai.provider.name']?.value).toBe('openrouter'); - expect(typeof chat?.attributes?.['gen_ai.usage.input_tokens']?.value).toBe('number'); - expect(typeof chat?.attributes?.['gen_ai.cost.total_tokens']?.value).toBe('number'); - - expect(executeTool?.attributes?.['gen_ai.tool.name']?.value).toBe('get_weather'); - expect(chat?.parent_span_id).toBe(invokeAgent?.span_id); - expect(executeTool?.parent_span_id).toBe(invokeAgent?.span_id); -}); - -test('nests a manual span raised inside a tool under that tool span', async ({ baseURL }) => { - const spansPromise = collectStreamedSpans( - APP, - spansOfTrace => usedTool('get_weather')(spansOfTrace) && spansOfTrace.some(span => span.name === 'resolve-weather'), - ); - - await runAgentTurn(baseURL!, newConversationId('manual-span'), 'What is the weather in Berlin?'); - - const spans = await spansPromise; - const executeTool = spans.find(span => getSpanOp(span) === 'gen_ai.execute_tool'); - const manualSpan = spans.find(span => span.name === 'resolve-weather'); - - expect(manualSpan?.attributes?.['weather.source']?.value).toBe('static-table'); - expect(manualSpan?.parent_span_id).toBe(executeTool?.span_id); -}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/tests/utils.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue/tests/utils.ts deleted file mode 100644 index 4a3932ada389..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue/tests/utils.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { expect } from '@playwright/test'; - -/** A conversation id nothing has used yet, so a settled record cannot end the wait early. */ -export function newConversationId(prefix: string): string { - return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -} - -/** - * Run one agent turn and wait for it to settle. - * - * `POST /:id` only admits the work — it returns `202` and the turn runs after — so this reads the - * conversation back until it reports a settlement. - */ -export async function runAgentTurn(baseURL: string, conversationId: string, message: string): Promise { - const url = `${baseURL}/agents/hello/${conversationId}`; - - const res = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ kind: 'user', body: message }), - }); - expect(res.status).toBe(202); - await res.text(); - - const deadline = Date.now() + 60_000; - while (Date.now() < deadline) { - const conversation = (await (await fetch(url)).json()) as { settlements?: unknown[] }; - if (conversation.settlements?.length) { - return; - } - await new Promise(resolve => setTimeout(resolve, 250)); - } - - throw new Error(`Flue turn for "${conversationId}" did not settle within 60s`); -} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/cloudflare-flue/wrangler.jsonc deleted file mode 100644 index 273779ec6976..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue/wrangler.jsonc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "./node_modules/wrangler/config-schema.json", - "name": "cloudflare-flue", - "compatibility_date": "2026-06-01", - "compatibility_flags": ["nodejs_compat"], - "migrations": [{ "tag": "v1", "new_sqlite_classes": ["FlueHelloAgent"] }], - // `vite build` snapshots these from the build-time environment into `dist/.dev.vars`, and - // `vite preview` reads that file in preference to its own environment. So both have to be set - // for the build, which is why `test:build` maps `E2E_OPENROUTER_API_KEY` across. - "secrets": { "required": ["E2E_TEST_DSN", "OPENROUTER_API_KEY"] }, -} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts index 324c9161cf69..87ae6c9cff41 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts @@ -31,6 +31,13 @@ export default { await env.AI.run('@cf/meta/llama-3.2-1b-instruct', { prompt: 'Say hi', max_tokens: 5 }); return Response.json({ traceId: spanContext?.traceId }); } + case '/test-workers-ai-jev': { + await env.AI.run('typesafe/jev', { + state: 'Help! My payouts have been failing for 3 days.', + questions: { is_urgent: { type: 'noul', instructions: 'Does this convey urgency?' } }, + }); + return Response.json({ traceId: spanContext?.traceId }); + } case '/test-span': return Response.json({ spanId: spanContext?.spanId, traceId: spanContext?.traceId }); case '/test-workflow-sleep': { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts index 2397db37089f..b391f2f8244b 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts @@ -93,3 +93,24 @@ test('Sends a Workers AI gen_ai span to Sentry', async () => { 'gen_ai.output.messages': expect.stringContaining('"role":"assistant"'), }); }); + +test('Sends a Workers AI gen_ai.evaluate span for a TypeSafe Jev call to Sentry', async () => { + const { traceId }: { traceId: string } = JSON.parse(await fetchFromWorker(`${workerUrl}/test-workers-ai-jev`, 200)); + + console.log(`Polling for gen_ai.evaluate span: sentry trace view ${traceTarget(traceId)}`); + + let spanId: string | undefined; + await expect + .poll(() => (spanId = findSpanInTrace(traceId, 'gen_ai.evaluate')?.event_id), EVENT_POLLING_OPTIONS) + .toBeDefined(); + + await expect + .poll(() => fetchSpanAttributes(traceId, spanId!), EVENT_POLLING_OPTIONS) + .toMatchObject({ + 'gen_ai.operation.name': 'evaluate', + 'gen_ai.request.model': 'typesafe/jev', + 'gen_ai.response.model': expect.stringMatching(/^jev-/), + 'gen_ai.input.messages': expect.stringContaining('My payouts have been failing'), + 'gen_ai.output.messages': expect.stringContaining('"is_urgent"'), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-static/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-static/tests/index.test.ts index 8d53746ee43d..81c4f72bfdda 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-static/tests/index.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-static/tests/index.test.ts @@ -41,7 +41,7 @@ test("Request processed by DurableObject's fetch is recorded", async ({ baseURL test('Websocket.webSocketMessage', async ({ baseURL }) => { const eventWaiter = waitForError('cloudflare-workers-static', event => { - return !!event.exception?.values?.[0]; + return event.exception?.values?.[0]?.value === 'Should be recorded in Sentry: webSocketMessage'; }); const url = new URL('/pass-to-object/ws', baseURL); url.protocol = url.protocol.replace('http', 'ws'); @@ -57,7 +57,7 @@ test('Websocket.webSocketMessage', async ({ baseURL }) => { test('Websocket.webSocketClose', async ({ baseURL }) => { const eventWaiter = waitForError('cloudflare-workers-static', event => { - return !!event.exception?.values?.[0]; + return event.exception?.values?.[0]?.value === 'Should be recorded in Sentry: webSocketClose'; }); const url = new URL('/pass-to-object/ws', baseURL); url.protocol = url.protocol.replace('http', 'ws'); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts index 3a5e72530801..ca79ad3c71ec 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts @@ -63,7 +63,7 @@ test("Request processed by DurableObject's fetch is recorded", async ({ baseURL test('Websocket.webSocketMessage', async ({ baseURL }) => { const eventWaiter = waitForError('cloudflare-workers', event => { - return !!event.exception?.values?.[0]; + return event.exception?.values?.[0]?.value === 'Should be recorded in Sentry: webSocketMessage'; }); const url = new URL('/pass-to-object/ws', baseURL); url.protocol = url.protocol.replace('http', 'ws'); @@ -79,7 +79,7 @@ test('Websocket.webSocketMessage', async ({ baseURL }) => { test('Websocket.webSocketClose', async ({ baseURL }) => { const eventWaiter = waitForError('cloudflare-workers', event => { - return !!event.exception?.values?.[0]; + return event.exception?.values?.[0]?.value === 'Should be recorded in Sentry: webSocketClose'; }); const url = new URL('/pass-to-object/ws', baseURL); url.protocol = url.protocol.replace('http', 'ws'); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/tests/index.test.ts index b815e7b7e3c7..b24d5f804ddd 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/tests/index.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/tests/index.test.ts @@ -63,7 +63,7 @@ test("Request processed by DurableObject's fetch is recorded", async ({ baseURL test('Websocket.webSocketMessage', async ({ baseURL }) => { const eventWaiter = waitForError('cloudflare-workersentrypoint', event => { - return !!event.exception?.values?.[0]; + return event.exception?.values?.[0]?.value === 'Should be recorded in Sentry: webSocketMessage'; }); const url = new URL('/pass-to-object/ws', baseURL); url.protocol = url.protocol.replace('http', 'ws'); @@ -79,7 +79,7 @@ test('Websocket.webSocketMessage', async ({ baseURL }) => { test('Websocket.webSocketClose', async ({ baseURL }) => { const eventWaiter = waitForError('cloudflare-workersentrypoint', event => { - return !!event.exception?.values?.[0]; + return event.exception?.values?.[0]?.value === 'Should be recorded in Sentry: webSocketClose'; }); const url = new URL('/pass-to-object/ws', baseURL); url.protocol = url.protocol.replace('http', 'ws'); diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/route-provider.$id.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/route-provider.$id.tsx new file mode 100644 index 000000000000..6134dbb17ab0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/route-provider.$id.tsx @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/remix'; +import { useEffect, useState } from 'react'; + +export default function RouteProvider() { + const [route, setRoute] = useState(); + + useEffect(() => { + setRoute(Sentry.resolveCurrentRoute() ?? 'unresolved'); + }, []); + + return
{route}
; +} diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/route-provider.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/route-provider.test.ts new file mode 100644 index 000000000000..a99e7ec07314 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/route-provider.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from '@playwright/test'; + +// The route provider is backed by the manifest the Vite plugin injects into the client bundle, so this +// fails if that manifest never reaches the browser. +test('resolves the parameterized route through the route provider', async ({ page }) => { + await page.goto('/route-provider/123'); + + await expect(page.locator('#resolved-route')).toHaveText('/route-provider/:id'); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/streamed/transactions.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/transactions.test.ts index cb9bb624f6bd..83bf22f3d6dc 100644 --- a/dev-packages/e2e-tests/test-applications/deno/tests/streamed/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/transactions.test.ts @@ -146,6 +146,10 @@ const SEGMENT_SPAN = { type: 'string', value: 'http:', }, + 'user.ip_address': { + type: 'string', + value: expect.any(String), + }, 'user_agent.original': { type: 'string', value: 'node', diff --git a/dev-packages/e2e-tests/test-applications/effect-3-node/src/app.ts b/dev-packages/e2e-tests/test-applications/effect-3-node/src/app.ts index 9de243f3cab3..3d6739e329f1 100644 --- a/dev-packages/e2e-tests/test-applications/effect-3-node/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/effect-3-node/src/app.ts @@ -6,6 +6,7 @@ import * as Cause from 'effect/Cause'; import * as Layer from 'effect/Layer'; import * as Logger from 'effect/Logger'; import * as LogLevel from 'effect/LogLevel'; +import * as Tracer from 'effect/Tracer'; import { createServer } from 'http'; const SentryLive = Layer.mergeAll( @@ -42,6 +43,31 @@ const router = HttpRouter.empty.pipe( }), ), + HttpRouter.get( + '/test-root-span', + Effect.gen(function* () { + yield* Effect.void.pipe(Effect.withSpan('root-span-request-marker')); + yield* Effect.sleep('10 millis').pipe(Effect.withSpan('detached-root-span', { root: true })); + return yield* HttpServerResponse.json({ status: 'ok' }); + }), + ), + + HttpRouter.get( + '/test-external-parent', + Effect.gen(function* () { + yield* Effect.sleep('10 millis').pipe( + Effect.withSpan('continued-span', { + parent: Tracer.externalSpan({ + traceId: 'fedcba0987654321fedcba0987654321', + spanId: '0987654321fedcba', + sampled: true, + }), + }), + ); + return yield* HttpServerResponse.json({ status: 'ok' }); + }).pipe(Effect.provide(Sentry.SentryEffectExternalSpanLayer)), + ), + HttpRouter.get( '/test-error', Effect.gen(function* () { diff --git a/dev-packages/e2e-tests/test-applications/effect-3-node/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/effect-3-node/tests/spans.test.ts index 9f689d35018b..4025bacdbfba 100644 --- a/dev-packages/e2e-tests/test-applications/effect-3-node/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/effect-3-node/tests/spans.test.ts @@ -65,3 +65,41 @@ test('Sends Effect spans with correct parent-child structure', async ({ baseURL expect(child.trace_id).toBe(segment.trace_id); } }); + +test('Sends a root: true span as its own segment in a new trace', async ({ baseURL }) => { + const requestSpansPromise = collectStreamedSpans( + 'effect-3-node', + spans => + spans.some( + span => + span.is_segment && + getSpanOp(span) === 'http.server' && + span.attributes['url.path']?.value === '/test-root-span', + ) && spans.some(span => span.name === 'root-span-request-marker'), + ); + const detachedSpanPromise = waitForStreamedSpan('effect-3-node', span => span.name === 'detached-root-span'); + + await fetch(`${baseURL}/test-root-span`); + + const [requestSpans, detachedSpan] = await Promise.all([requestSpansPromise, detachedSpanPromise]); + const segment = requestSpans.find(span => span.is_segment && getSpanOp(span) === 'http.server')!; + expect(segment.name).toBe('http.server GET'); + expect(requestSpans.filter(span => !span.is_segment).map(span => span.name)).toEqual(['root-span-request-marker']); + + expect(detachedSpan.is_segment).toBe(true); + expect(detachedSpan.parent_span_id).toBeUndefined(); + expect(detachedSpan.trace_id).not.toBe(segment.trace_id); +}); + +test('Continues the trace of a Tracer.externalSpan parent with the external span layer', async ({ baseURL }) => { + const spanPromise = waitForStreamedSpan('effect-3-node', span => span.name === 'continued-span'); + + await fetch(`${baseURL}/test-external-parent`); + + const span = await spanPromise; + expect(span).toMatchObject({ + is_segment: true, + trace_id: 'fedcba0987654321fedcba0987654321', + parent_span_id: '0987654321fedcba', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/effect-4-browser/package.json b/dev-packages/e2e-tests/test-applications/effect-4-browser/package.json index f3f429c25b9f..17bd84eba21c 100644 --- a/dev-packages/e2e-tests/test-applications/effect-4-browser/package.json +++ b/dev-packages/e2e-tests/test-applications/effect-4-browser/package.json @@ -13,7 +13,7 @@ "dependencies": { "@sentry/effect": "latest || *", "@types/node": "^18.19.1", - "effect": "^4.0.0-beta.50", + "effect": "^4.0.0", "typescript": "~5.0.0" }, "devDependencies": { diff --git a/dev-packages/e2e-tests/test-applications/effect-4-node/package.json b/dev-packages/e2e-tests/test-applications/effect-4-node/package.json index 460bfb57ec45..a639956c51fb 100644 --- a/dev-packages/e2e-tests/test-applications/effect-4-node/package.json +++ b/dev-packages/e2e-tests/test-applications/effect-4-node/package.json @@ -12,10 +12,10 @@ "test:assert": "pnpm test" }, "dependencies": { - "@effect/platform-node": "^4.0.0-rc.118", + "@effect/platform-node": "^4.0.0", "@sentry/effect": "file:../../packed/sentry-effect-packed.tgz", "@types/node": "^18.19.1", - "effect": "^4.0.0-rc.118", + "effect": "^4.0.0", "typescript": "~5.0.0" }, "devDependencies": { diff --git a/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts b/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts index db5b1fae44eb..0bcc70088359 100644 --- a/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts @@ -101,6 +101,33 @@ const Routes = Layer.mergeAll( }), ), + HttpRouter.add( + 'GET', + '/test-root-span', + Effect.gen(function* () { + yield* Effect.void.pipe(Effect.withSpan('root-span-request-marker')); + yield* Effect.sleep('10 millis').pipe(Effect.withSpan('detached-root-span', { root: true })); + return yield* HttpServerResponse.json({ status: 'ok' }); + }), + ), + + HttpRouter.add( + 'GET', + '/test-external-parent', + Effect.gen(function* () { + yield* Effect.sleep('10 millis').pipe( + Effect.withSpan('continued-span', { + parent: Tracer.externalSpan({ + traceId: 'fedcba0987654321fedcba0987654321', + spanId: '0987654321fedcba', + sampled: true, + }), + }), + ); + return yield* HttpServerResponse.json({ status: 'ok' }); + }).pipe(Effect.provide(Sentry.SentryEffectExternalSpanLayer)), + ), + HttpRouter.add( 'GET', '/test-error', diff --git a/dev-packages/e2e-tests/test-applications/effect-4-node/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/effect-4-node/tests/spans.test.ts index 2a468928c798..395f5263f286 100644 --- a/dev-packages/e2e-tests/test-applications/effect-4-node/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/effect-4-node/tests/spans.test.ts @@ -65,3 +65,41 @@ test('Sends Effect spans with correct parent-child structure', async ({ baseURL expect(child.trace_id).toBe(segment.trace_id); } }); + +test('Sends a root: true span as its own segment in a new trace', async ({ baseURL }) => { + const requestSpansPromise = collectStreamedSpans( + 'effect-4-node', + spans => + spans.some( + span => + span.is_segment && + getSpanOp(span) === 'http.server' && + span.attributes['url.path']?.value === '/test-root-span', + ) && spans.some(span => span.name === 'root-span-request-marker'), + ); + const detachedSpanPromise = waitForStreamedSpan('effect-4-node', span => span.name === 'detached-root-span'); + + await fetch(`${baseURL}/test-root-span`); + + const [requestSpans, detachedSpan] = await Promise.all([requestSpansPromise, detachedSpanPromise]); + const segment = requestSpans.find(span => span.is_segment && getSpanOp(span) === 'http.server')!; + expect(segment.name).toBe('http.server GET'); + expect(requestSpans.filter(span => !span.is_segment).map(span => span.name)).toEqual(['root-span-request-marker']); + + expect(detachedSpan.is_segment).toBe(true); + expect(detachedSpan.parent_span_id).toBeUndefined(); + expect(detachedSpan.trace_id).not.toBe(segment.trace_id); +}); + +test('Continues the trace of a Tracer.externalSpan parent with the external span layer', async ({ baseURL }) => { + const spanPromise = waitForStreamedSpan('effect-4-node', span => span.name === 'continued-span'); + + await fetch(`${baseURL}/test-external-parent`); + + const span = await spanPromise; + expect(span).toMatchObject({ + is_segment: true, + trace_id: 'fedcba0987654321fedcba0987654321', + parent_span_id: '0987654321fedcba', + }); +}); 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 5f574018e1e0..87d584397cd9 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 @@ -15,7 +15,7 @@ "@types/node": "^18.19.1", "@types/react": "18.0.26", "@types/react-dom": "18.0.9", - "next": "^15", + "next": "^15.5.24", "react": "latest", "react-dom": "latest", "typescript": "~5.0.0" 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 f3bd1c6360f6..32b395b278a5 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json @@ -15,7 +15,7 @@ "@types/node": "^18.19.1", "@types/react": "18.0.26", "@types/react-dom": "18.0.9", - "next": "15.5.21", + "next": "15.5.24", "next-intl": "^4.3.12", "react": "latest", "react-dom": "latest", 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 b03150a518a4..6c8a74f4b432 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 @@ -20,7 +20,7 @@ "@trpc/client": "~11.8.0", "@trpc/react-query": "~11.8.0", "@trpc/server": "~11.8.0", - "next": "^15.5.13", + "next": "^15.5.24", "react": "^19.1.0", "react-dom": "^19.1.0", "server-only": "^0.0.1", 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 1ca1e3f1e6ed..f6013d99cd95 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json @@ -20,7 +20,7 @@ "@types/react": "18.0.26", "@types/react-dom": "18.0.9", "ai": "^3.0.0", - "next": "15.5.21", + "next": "15.5.24", "react": "latest", "react-dom": "latest", "typescript": "~5.0.0", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts index 5025d7d2e104..9c6245a06f5c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts @@ -3,15 +3,12 @@ import { waitForTransaction } from '@sentry-internal/test-utils'; // Origin links (`sentry.link.type: 'cache_origin'` on `cache.get` hit spans, pointing at the // filling `cache.put`) for `use cache` in nested layout trees under `app/(cached-nesting)/`. -// Not implemented yet — every test is `test.fail()` with the final expected assertions. // A `use cache` layout between dynamic segments. The layout entry is keyed by the awaited [id] -// param. If Next serves the entry from the prerendered shell (Resume Data Cache) instead of the -// cache handlers, there is no `cache.get` span at all — then this stays failing until Next -// exposes RDC reads. +// param. These runtime-filled entries are read through the cache handlers here; entries served +// from the prerendered shell (Resume Data Cache) would produce no `cache.get` span at all until +// Next exposes RDC reads. test('links a cached layout hit to the trace that filled the layout entry', async ({ request }) => { - test.fail(); - const id = crypto.randomUUID(); const missTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { @@ -56,8 +53,6 @@ test('links a cached layout hit to the trace that filled the layout entry', asyn test('links a cached leaf component under dynamic layouts to the trace that filled the leaf entry', async ({ request, }) => { - test.fail(); - const id = crypto.randomUUID(); const missTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { @@ -101,7 +96,6 @@ test('links a cached leaf component under dynamic layouts to the trace that fill // spans point at two different origin traces. test('links two cached levels to different origin traces after the layout expires', async ({ request }) => { test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production'); - test.fail(); const id = crypto.randomUUID(); @@ -180,8 +174,6 @@ test('links two cached levels to different origin traces after the layout expire test('links a shared layout hit on a sibling route to the trace of the route that filled the entry', async ({ request, }) => { - test.fail(); - const id = crypto.randomUUID(); const fillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-page.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-page.spec.ts index a0acf25df078..6d3f36e297d0 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-page.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-page.spec.ts @@ -4,15 +4,13 @@ import { waitForTransaction } from '@sentry-internal/test-utils'; // Origin links for `use cache` inside rendered pages (cached components and nested cached // functions). Target behavior: a cache hit records a `cache.get` span carrying a // `sentry.link.type: 'cache_origin'` span link to the `cache.put` span of the trace that filled -// the entry. Not implemented yet — every test is `test.fail()`. +// the entry. // Two cached sibling components are two cache entries (props are part of the key), so one request -// carries one `cache.get` hit span per section, each linking to its own fill. The components sit -// in a dynamic hole: entries served from the prerendered shell (Resume Data Cache) never reach -// the cache handlers and produce no spans until Next.js exposes RDC reads. +// carries one `cache.get` hit span per section, each linking to its own fill. These runtime-filled entries are read +// through the cache handlers; entries served from the prerendered shell (Resume Data Cache) would +// produce no spans at all until Next.js exposes RDC reads. test('links each sibling component hit to the fill of its own entry', async ({ request }) => { - test.fail(); - const id = crypto.randomUUID(); const missTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { @@ -65,7 +63,6 @@ test('links each sibling component hit to the fill of its own entry', async ({ r test("links a nested cache hit inside another entry's refill to the original fill trace", async ({ request }) => { test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production'); - test.fail(); const id = crypto.randomUUID(); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts index 2f604f0402f3..e1e7a468f7cd 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts @@ -1,14 +1,11 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; -// Origin links for `use cache` in route handlers. Target behavior: a cache hit records a -// `cache.get` span carrying a `sentry.link.type: 'cache_origin'` span link to the `cache.put` -// span of the trace that filled the entry; unknown origin means no link. Not implemented yet — -// every test is `test.fail()`; shipping the feature should only require deleting those lines. +// Origin links for `use cache` in route handlers: a cache hit records a `cache.get` span +// carrying a `sentry.link.type: 'cache_origin'` span link to the `cache.put` span of the trace +// that filled the entry; unknown origin means no link. test('links a route handler cache hit to the trace that filled the entry', async ({ request }) => { - test.fail(); - // A fresh id makes the first request a guaranteed cache miss (the id is part of the cache key) // even when the test is retried against the same server. const id = crypto.randomUUID(); @@ -55,7 +52,6 @@ test('links a route handler cache hit to the trace that filled the entry', async test('moves the origin link to the refill trace after the entry expires', async ({ request }) => { test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production'); - test.fail(); const id = crypto.randomUUID(); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts index d456686f24ec..abd984c429dc 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts @@ -4,15 +4,12 @@ import { CACHE_ORIGIN_LINK_ATTRIBUTES, findCacheSpan } from './cacheOriginLinks- // Origin links (`sentry.link.type: 'cache_origin'` on `cache.get` hit spans, pointing at the // filling `cache.put`) for `use cache` in nested layout trees under `app/(cached-nesting)/`. -// Not implemented yet — every test is `test.fail()` with the final expected assertions. // A `use cache` layout between dynamic segments. The layout entry is keyed by the awaited [id] -// param. If Next serves the entry from the prerendered shell (Resume Data Cache) instead of the -// cache handlers, there is no `cache.get` span at all — then this stays failing until Next -// exposes RDC reads. +// param. These runtime-filled entries are read through the cache handlers here; entries served +// from the prerendered shell (Resume Data Cache) would produce no `cache.get` span at all until +// Next exposes RDC reads. test('links a cached layout hit to the trace that filled the layout entry', async ({ request }) => { - test.fail(); - const id = crypto.randomUUID(); const missSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { @@ -57,8 +54,6 @@ test('links a cached layout hit to the trace that filled the layout entry', asyn test('links a cached leaf component under dynamic layouts to the trace that filled the leaf entry', async ({ request, }) => { - test.fail(); - const id = crypto.randomUUID(); const missSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { @@ -102,7 +97,6 @@ test('links a cached leaf component under dynamic layouts to the trace that fill // spans point at two different origin traces. test('links two cached levels to different origin traces after the layout expires', async ({ request }) => { test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production'); - test.fail(); const id = crypto.randomUUID(); @@ -185,8 +179,6 @@ test('links two cached levels to different origin traces after the layout expire test('links a shared layout hit on a sibling route to the trace of the route that filled the entry', async ({ request, }) => { - test.fail(); - const id = crypto.randomUUID(); const fillSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-page.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-page.spec.ts index fd8a86a5c848..0bac1aeec817 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-page.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-page.spec.ts @@ -5,15 +5,13 @@ import { CACHE_ORIGIN_LINK_ATTRIBUTES } from './cacheOriginLinks-utils'; // Origin links for `use cache` inside rendered pages (cached components and nested cached // functions). Target behavior: a cache hit records a `cache.get` span carrying a // `sentry.link.type: 'cache_origin'` span link to the `cache.put` span of the trace that filled -// the entry. Not implemented yet — every test is `test.fail()`. +// the entry. // Two cached sibling components are two cache entries (props are part of the key), so one request -// carries one `cache.get` hit span per section, each linking to its own fill. The components sit -// in a dynamic hole: entries served from the prerendered shell (Resume Data Cache) never reach -// the cache handlers and produce no spans until Next.js exposes RDC reads. +// carries one `cache.get` hit span per section, each linking to its own fill. These runtime-filled entries are read +// through the cache handlers; entries served from the prerendered shell (Resume Data Cache) would +// produce no spans at all until Next.js exposes RDC reads. test('links each sibling component hit to the fill of its own entry', async ({ request }) => { - test.fail(); - const id = crypto.randomUUID(); const missSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { @@ -70,7 +68,6 @@ test('links each sibling component hit to the fill of its own entry', async ({ r test("links a nested cache hit inside another entry's refill to the original fill trace", async ({ request }) => { test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production'); - test.fail(); const id = crypto.randomUUID(); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts index 09148406915d..cf1d5351d49b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts @@ -2,14 +2,11 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; import { CACHE_ORIGIN_LINK_ATTRIBUTES, findCacheSpan } from './cacheOriginLinks-utils'; -// Origin links for `use cache` in route handlers. Target behavior: a cache hit records a -// `cache.get` span carrying a `sentry.link.type: 'cache_origin'` span link to the `cache.put` -// span of the trace that filled the entry; unknown origin means no link. Not implemented yet — -// every test is `test.fail()`; shipping the feature should only require deleting those lines. +// Origin links for `use cache` in route handlers: a cache hit records a `cache.get` span +// carrying a `sentry.link.type: 'cache_origin'` span link to the `cache.put` span of the trace +// that filled the entry; unknown origin means no link. test('links a route handler cache hit to the trace that filled the entry', async ({ request }) => { - test.fail(); - // A fresh id makes the first request a guaranteed cache miss (the id is part of the cache key) // even when the test is retried against the same server. const id = crypto.randomUUID(); @@ -56,7 +53,6 @@ test('links a route handler cache hit to the trace that filled the entry', async test('moves the origin link to the refill trace after the entry expires', async ({ request }) => { test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production'); - test.fail(); const id = crypto.randomUUID(); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/flush.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/flush.ts new file mode 100644 index 000000000000..1bb06c6e1a6d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/flush.ts @@ -0,0 +1,7 @@ +import { flush } from '@sentry/nitro'; +import { defineHandler } from 'nitro/h3'; + +export default defineHandler(async () => { + await flush(); + return { status: 'ok' }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-error-400.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-error-400.ts new file mode 100644 index 000000000000..760a6ad17852 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-error-400.ts @@ -0,0 +1,5 @@ +import { defineHandler, HTTPError } from 'nitro/h3'; + +export default defineHandler(() => { + throw new HTTPError({ status: 400, message: 'Explicit 400 test error' }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/errors.test.ts index 1441ecd5ecab..18cbd7ca6709 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/errors.test.ts @@ -1,16 +1,10 @@ import { expect, test } from '@playwright/test'; -import { waitForError } from '@sentry-internal/test-utils'; +import { waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('Sends an error event to Sentry', async ({ request }) => { - // The thrown error is reported twice: once via the h3 tracing channel and once via Nitro's `error` - // hook (which sees it wrapped in an `HTTPError`). Match on the mechanism so we deterministically - // await the event under test instead of whichever arrives first. const errorEventPromise = waitForError('nitro-3', event => { - return ( - !event.type && - !!event.exception?.values?.some( - v => v.value === 'This is a test error' && v.mechanism?.type === 'auto.http.nitro.onTraceError', - ) + return !!event.exception?.values?.some( + v => v.value === 'This is a test error' && v.mechanism?.type === 'auto.function.nitro.captureErrorHook', ); }); @@ -20,16 +14,45 @@ test('Sends an error event to Sentry', async ({ request }) => { const errorEvent = await errorEventPromise; - expect(errorEvent.exception?.values).toHaveLength(1); - - expect(errorEvent.exception?.values?.[0]?.type).toBe('Error'); - expect(errorEvent.exception?.values?.[0]?.value).toBe('This is a test error'); - expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( + expect(errorEvent.exception?.values).toEqual([ + expect.objectContaining({ + type: 'Error', + value: 'This is a test error', + }), expect.objectContaining({ - handled: false, - type: 'auto.http.nitro.onTraceError', + type: 'HTTPError', + value: 'This is a test error', + mechanism: expect.objectContaining({ + handled: false, + type: 'auto.function.nitro.captureErrorHook', + }), }), - ); + ]); +}); + +test('Does not send an explicitly thrown 400 error to Sentry', async ({ request }) => { + let errorReceived = false; + + void waitForError('nitro-3', event => { + if (event.exception?.values?.some(v => v.value === 'Explicit 400 test error')) { + errorReceived = true; + return true; + } + return false; + }); + + const flushSpanPromise = waitForStreamedSpan('nitro-3', span => { + return span.is_segment && span.name === 'GET /api/flush'; + }); + + const response = await request.get('/api/test-error-400'); + expect(response.status()).toBe(400); + + const flushResponse = await request.get('/api/flush'); + expect(flushResponse.status()).toBe(200); + await flushSpanPromise; + + expect(errorReceived).toBe(false); }); test('Does not send 404 errors to Sentry', async ({ request }) => { diff --git a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/.gitignore b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/.gitignore new file mode 100644 index 000000000000..795f6b6d40d1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/.gitignore @@ -0,0 +1,5 @@ +node_modules +pnpm-lock.yaml +dist +test-results +playwright-report 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 new file mode 100644 index 000000000000..ef1b771dd545 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/package.json @@ -0,0 +1,40 @@ +{ + "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.63.0", + "@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)" + } + ] + }, + "volta": { + "node": "24.15.0", + "extends": "../../package.json" + } +} 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 new file mode 100644 index 000000000000..ef4764ce8d73 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/playwright.config.ts @@ -0,0 +1,28 @@ +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 new file mode 100644 index 000000000000..7ad878cdff26 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/src/app.ts @@ -0,0 +1,132 @@ +// `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' }); + +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, + temperature: 0, + 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, + temperature: 0, + 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, + temperature: 0, + 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 new file mode 100644 index 000000000000..7d031b0e6d8e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/src/instrument.ts @@ -0,0 +1,10 @@ +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 new file mode 100644 index 000000000000..44831a6d1a59 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/tests/send-to-sentry.test.ts @@ -0,0 +1,152 @@ +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.request.temperature']).toBe(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 new file mode 100644 index 000000000000..521b830eefcb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/tests/utils/sentry-api.ts @@ -0,0 +1,131 @@ +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-anthropic-send-to-sentry/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/tsconfig.json new file mode 100644 index 000000000000..795574fd9df2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/tsconfig.json @@ -0,0 +1,13 @@ +{ + "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/nuxt-4-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts index bd74c74d5997..14d64149cefc 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts @@ -20,6 +20,8 @@ const keepClientSourceMaps = process.env.E2E_KEEP_CLIENT_SOURCEMAPS === 'true'; const isStaticBuild = process.env.NUXT_COMMAND === 'generate'; const CLIENT_OUTPUT = path.join('.output', 'public'); +/** Vite's own output directory. Nitro copies it to `CLIENT_OUTPUT`, and Sentry deletes no maps here. */ +const CLIENT_BUILD_OUTPUT = path.join('.nuxt', 'dist', 'client'); const SERVER_OUTPUT = path.join('.output', 'server'); /** Both markers sit in comments, so bundlers strip them from the code but keep them in `sourcesContent`. */ @@ -36,6 +38,26 @@ function filesContaining(dir: string, needle: string): string[] { .filter(file => fs.readFileSync(file, 'utf8').includes(needle)); } +/** + * Rolldown emits no source map for a chunk without mappable source, such as Vue's export helper. + * Such a chunk still gets a debug ID, but there is no map to upload for it. + */ +function debugIdsOfChunksWithoutSourceMap(dir: string): Set { + const debugIds = new Set(); + + for (const file of filesContaining(dir, 'sentry-dbid-')) { + if (file.endsWith('.map') || fs.existsSync(`${file}.map`)) { + continue; + } + + for (const match of fs.readFileSync(file, 'utf8').matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { + debugIds.add((match[1] as string).toLowerCase()); + } + } + + return debugIds; +} + console.log( `Variant: ${isStaticBuild ? 'nuxt generate' : 'nuxt build'}, ` + `client source maps ${keepClientSourceMaps ? 'kept' : 'deleted'}\n`, @@ -139,11 +161,15 @@ assert.deepEqual(malformedDebugIds, [], 'Expected every uploaded debug ID to be // An uploaded map is only reachable at runtime if the shipped bundle claims the same ID. Inspecting // the upload alone cannot show this. +const debugIdsWithoutSourceMap = debugIdsOfChunksWithoutSourceMap(CLIENT_BUILD_OUTPUT); + for (const outputDir of isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER_OUTPUT]) { const injectedDebugIds = findInjectedDebugIds({ outputDir }); assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); - const unuploaded = injectedDebugIds.filter(debugId => !uploadedDebugIds.has(debugId)); + const unuploaded = injectedDebugIds.filter( + debugId => !uploadedDebugIds.has(debugId) && !debugIdsWithoutSourceMap.has(debugId), + ); assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded sourcemap`); console.log(` ${outputDir}: ${injectedDebugIds.length} injected debug ID(s), all uploaded`); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/route-provider/[id].vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/route-provider/[id].vue new file mode 100644 index 000000000000..15ecf3348e94 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/route-provider/[id].vue @@ -0,0 +1,14 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/route-provider.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/route-provider.test.ts new file mode 100644 index 000000000000..4796384fbb47 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/route-provider.test.ts @@ -0,0 +1,8 @@ +import { expect, test } from '@playwright/test'; + +// The route provider reads the router off the Nuxt app, so this fails if the plugin never registers it. +test('resolves the parameterized route through the route provider', async ({ page }) => { + await page.goto('/route-provider/123'); + + await expect(page.locator('#resolved-route')).toHaveText('/route-provider/:id()'); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/trace-propagation.test.ts index b62e784ff3e3..bcdb5e2f34fa 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/trace-propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/trace-propagation.test.ts @@ -19,7 +19,7 @@ test.describe('Trace propagation', () => { test('should have trace connection', async ({ page }) => { const serverTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET *'; + return transactionEvent.transaction === 'GET /'; }); const clientTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx index 756115577e68..9d52a907bcbe 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx @@ -17,11 +17,37 @@ function HomePage(handle: Handle>) {

Sentry Remix 3

+ {/* No `data-rmx-document`, so the runtime intercepts this through the Navigation API. */} + + User + + + ); } +function UserPage(handle: Handle<{ id?: string }>) { + return () => ( + + + + User + + +

User {handle.props.id}

+ + + ); +} + +let slowStarted = false; + export default createController(routes, { actions: { async assets(context) { @@ -31,10 +57,23 @@ export default createController(routes, { return context.render(); }, user(context) { - return Response.json({ id: context.params.id }); + return context.render(); }, teapot() { return new Response("I'm a teapot", { status: 418 }); }, + boom() { + throw new Error('Route handler failed'); + }, + // Long enough for a test to disconnect mid request. `/slow-started` tells the test when the handler + // is running, so the disconnect lands inside it rather than before it. + async slow() { + slowStarted = true; + await new Promise(resolve => setTimeout(resolve, 3000)); + return new Response('slow'); + }, + slowStarted() { + return new Response(slowStarted ? '1' : '0'); + }, }, }); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts index 5f0ebdc1248e..c18e0f0a8967 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts @@ -1,9 +1,37 @@ -import { run } from 'remix/ui'; +import * as Sentry from '@sentry/remix/v3/client'; +import { createElement, run } from 'remix/ui'; + +import { throwError } from './throw-error.ts'; + +// Not Node. The asset server substitutes this when it compiles the module, from the `define` map in +// `app/assets.ts`. +declare const process: { env: Record }; + +Sentry.init({ + dsn: process.env.E2E_TEST_DSN, + tunnel: 'http://localhost:3061/', + tracesSampleRate: 1.0, +}); -// No Sentry here yet: `@sentry/remix/v3/client` does not export `init` until the browser SDK lands. export const app = run({ async loadModule(moduleUrl, exportName) { - let mod = await import(moduleUrl); + const mod = await import(moduleUrl); return mod[exportName]; }, }); + +function Boom(): () => never { + return () => { + throw new Error('Component render failed'); + }; +} + +document.addEventListener('click', event => { + const id = (event.target as HTMLElement | null)?.id; + if (id === 'component-error') { + void app.frames.top.replace(createElement(Boom, {})); + } + if (id === 'throw-error') { + throwError(); + } +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/throw-error.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/throw-error.ts new file mode 100644 index 000000000000..d8540df85001 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/throw-error.ts @@ -0,0 +1,3 @@ +export function throwError(): never { + throw new Error('Remix 3 client error'); +} diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts index a3c153578aa6..3cb55a9ab90d 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts @@ -6,7 +6,17 @@ export const assets = createAssetServer({ allowFiles: ['app/routes.ts', 'app/**/public/**'], allowPackages: ['remix', '@sentry/remix'], minify: true, + scripts: { + define: { 'process.env.E2E_TEST_DSN': JSON.stringify(process.env.E2E_TEST_DSN) }, + }, watch: false, + scripts: { + // No bundler means no build time env inlining, so `define` is the only way to get configuration + // into a browser module. The asset server substitutes these when it compiles. + define: { + 'process.env.E2E_TEST_DSN': JSON.stringify(process.env.E2E_TEST_DSN), + }, + }, }); const entry = 'app/actions/public/entry.ts'; diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/routes.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/routes.ts index 77b32281374b..37c442cc6c82 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/routes.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/routes.ts @@ -5,4 +5,7 @@ export const routes = route({ home: '/', user: get('/users/:id'), teapot: get('/teapot'), + boom: get('/boom'), + slow: get('/slow'), + slowStarted: get('/slow-started'), }); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/server.ts b/dev-packages/e2e-tests/test-applications/remix-v3/server.ts index 56408af2bbe9..c9ac4f00d087 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/server.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/server.ts @@ -13,12 +13,13 @@ Sentry.init({ const port = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3060; const server = http.createServer( - createRequestListener(async request => { - try { - return await router.fetch(request); - } catch { - return new Response('Internal Server Error', { status: 500 }); + createRequestListener(request => { + // Handled outside the router on purpose: the only hook that can see this is the listener's + // `onError`, which is the case the router middleware cannot cover. + if (new URL(request.url).pathname === '/plain-throw') { + throw new Error('Plain handler failed'); } + return router.fetch(request); }), ); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/tests/browser-transform.test.ts b/dev-packages/e2e-tests/test-applications/remix-v3/tests/browser-transform.test.ts new file mode 100644 index 000000000000..08b73e2ceaad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/tests/browser-transform.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from '@playwright/test'; + +test('the served remix/ui runtime is instrumented', async ({ page, baseURL }) => { + const served: string[] = []; + page.on('response', response => { + if (response.request().resourceType() === 'script') { + served.push(response.url()); + } + }); + + await page.goto('/', { waitUntil: 'load' }); + + const runModule = served.find(url => /@remix-run\/ui\/dist\/runtime\/run\.js/.test(decodeURIComponent(url))); + expect(runModule, 'run.js was not served').toBeDefined(); + + const code = await (await fetch(runModule as string)).text(); + expect(code).toMatch(/diagnosticsChannelShim\.js/); + // A CommonJS `require` here would mean the transform emitted the wrong module type. + expect(code).not.toMatch(/\brequire\(/); +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts b/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts new file mode 100644 index 000000000000..79d36cf682c0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +const APP_NAME = 'remix-v3'; + +test('sends a pageload span', async ({ page }) => { + const spanPromise = waitForStreamedSpan(APP_NAME, span => getSpanOp(span) === 'pageload' && span.is_segment === true); + + await page.goto('/'); + + const span = await spanPromise; + // Ordinary document load, so this span comes from the upstream integration and takes the low + // cardinality streaming name. + expect(span.name).toBe('Pageload'); + expect(span.attributes?.['sentry.origin']?.value).toBe('auto.pageload.browser'); +}); + +test('sends a navigation span for a link the runtime intercepts', async ({ page }) => { + await page.goto('/'); + + // Selected by origin, not just op. `remix/ui` never touches History, so a span from the upstream + // handler would mean this SDK did not produce it. + const spanPromise = waitForStreamedSpan( + APP_NAME, + span => + getSpanOp(span) === 'navigation' && span.attributes?.['sentry.origin']?.value === 'auto.navigation.remix_v3', + ); + + await page.locator('#to-user').click(); + await expect(page.locator('#user')).toBeVisible(); + + const span = await spanPromise; + expect(span.is_segment).toBe(true); + expect(span.name).toBe('Navigation'); +}); + +test('captures a component render error the runtime never rethrows', async ({ page }) => { + await page.goto('/'); + + const errorPromise = waitForError(APP_NAME, event => { + return !event.type && event.exception?.values?.[0]?.value === 'Component render failed'; + }); + + await page.locator('#component-error').click(); + + const error = await errorPromise; + expect(error.exception?.values?.[0]?.mechanism).toEqual({ handled: false, type: 'auto.ui.remix_v3' }); +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/tests/debug-ids.test.ts b/dev-packages/e2e-tests/test-applications/remix-v3/tests/debug-ids.test.ts new file mode 100644 index 000000000000..b91955111279 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/tests/debug-ids.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +const DEBUG_ID = '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'; + +function getDebugId(code: string): string | undefined { + return code.match(new RegExp(`\\n//# debugId=(${DEBUG_ID})$`))?.[1]; +} + +test('every served browser module carries a debug ID', async ({ page, baseURL }) => { + const moduleUrls: string[] = []; + page.on('response', response => { + if (response.request().resourceType() === 'script' && response.url().startsWith(`${baseURL}/assets/`)) { + moduleUrls.push(response.url()); + } + }); + + // `load` fires once every module script and modulepreload has executed, which is what is collected. + await page.goto('/', { waitUntil: 'load' }); + + expect(moduleUrls).toContainEqual(expect.stringContaining('/assets/app/actions/public/entry.ts')); + + for (const url of moduleUrls) { + const code = await (await fetch(url)).text(); + const debugId = getDebugId(code); + + expect(debugId, `${url} has no debugId comment`).toBeDefined(); + expect(code).toContain(`sentry-dbid-${debugId}`); + } +}); + +// The app does not configure source maps, so they are hidden, like the other meta framework SDKs do. +test('source maps are not exposed when the app did not ask for them', async ({ baseURL }) => { + const url = `${baseURL}/assets/app/actions/public/entry.ts`; + + const code = await (await fetch(url)).text(); + const sourceMapResponse = await fetch(`${url}.map`); + + expect(code).not.toContain('//# sourceMappingURL='); + expect(sourceMapResponse.status).toBe(404); +}); + +test('the debug ID of a module is stable across requests', async ({ baseURL }) => { + const url = `${baseURL}/assets/app/actions/public/throw-error.ts`; + + const first = getDebugId(await (await fetch(url)).text()); + const second = getDebugId(await (await fetch(url)).text()); + + expect(first).toMatch(new RegExp(`^${DEBUG_ID}$`)); + expect(second).toBe(first); +}); + +test('a client error carries the debug IDs of the modules in its stack trace', async ({ page, baseURL }) => { + const errorPromise = waitForError('remix-v3', event => { + return !event.type && event.exception?.values?.[0]?.value === 'Remix 3 client error'; + }); + + await page.goto('/'); + await page.locator('#throw-error').click(); + + const errorEvent = await errorPromise; + + const moduleUrl = `${baseURL}/assets/app/actions/public/throw-error.ts`; + const debugId = getDebugId(await (await fetch(moduleUrl)).text()); + + expect(errorEvent.debug_meta?.images).toContainEqual({ + type: 'sourcemap', + code_file: moduleUrl, + debug_id: debugId, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/tests/server-errors.test.ts b/dev-packages/e2e-tests/test-applications/remix-v3/tests/server-errors.test.ts new file mode 100644 index 000000000000..01e9c2c5810f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/tests/server-errors.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +const APP_NAME = 'remix-v3'; + +test('reports an error thrown in a route handler, with its route', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => event.exception?.values?.[0]?.value === 'Route handler failed'); + + await fetch(`${baseURL}/boom`); + + const event = await errorPromise; + expect(event.exception?.values?.[0]?.mechanism).toEqual({ handled: false, type: 'auto.http.remix_v3.middleware' }); + expect(event.transaction).toBe('GET /boom'); +}); + +test('reports an error from a fetch handler that is not the router', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => event.exception?.values?.[0]?.value === 'Plain handler failed'); + + const response = await fetch(`${baseURL}/plain-throw`); + + expect(response.status).toBe(500); + const event = await errorPromise; + expect(event.exception?.values?.[0]?.mechanism).toEqual({ handled: false, type: 'auto.http.remix_v3.on_error' }); +}); + +test('does not report a request the client aborted', async ({ baseURL }) => { + const seen: string[] = []; + void waitForError(APP_NAME, event => { + seen.push(event.exception?.values?.[0]?.value ?? ''); + return false; + }); + + const controller = new AbortController(); + const slow = fetch(`${baseURL}/slow`, { signal: controller.signal }).catch(() => undefined); + await expect.poll(async () => (await fetch(`${baseURL}/slow-started`)).text()).toBe('1'); + controller.abort(); + await slow; + + // A later real error proves the pipeline is live, so an abort error would have arrived before it. + const sentinel = waitForError(APP_NAME, event => event.exception?.values?.[0]?.value === 'Route handler failed'); + await fetch(`${baseURL}/boom`); + await sentinel; + + expect(seen.filter(value => value !== 'Route handler failed')).toEqual([]); +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/tests/sourcemap-upload.test.ts b/dev-packages/e2e-tests/test-applications/remix-v3/tests/sourcemap-upload.test.ts new file mode 100644 index 000000000000..7eacfe71d8d8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/tests/sourcemap-upload.test.ts @@ -0,0 +1,60 @@ +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import { createRequire } from 'node:module'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { expect, test } from '@playwright/test'; + +const DEBUG_ID = /\/\/# debugId=([0-9a-f-]{36})/; + +// Not a listed subpath, so it is reached from the package root. +const script = path.join( + path.dirname(createRequire(import.meta.url).resolve('@sentry/remix/package.json')), + 'scripts', + 'sentry-remix-v3-upload-sourcemaps.mjs', +); + +test('emits the module graph with one map per module, all sharing the served debug IDs', async ({ baseURL }) => { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'remix-v3-upload-')); + + const output = execFileSync( + 'node', + [ + '--import', + '@sentry/remix/v3/node', + script, + '--entry', + 'app/actions/public/entry.ts', + '--dry-run', + '--out-dir', + outDir, + ], + { cwd: process.cwd(), env: { ...process.env, NODE_ENV: 'production' }, encoding: 'utf8' }, + ); + expect(output).toMatch(/emitted \d+ modules/); + + const modules = walk(outDir).filter(file => !file.endsWith('.map')); + expect(modules.length).toBeGreaterThan(1); + + for (const file of modules) { + const debugId = fs.readFileSync(file, 'utf8').match(DEBUG_ID)?.[1]; + expect(debugId, `${file} has no debug ID`).toBeDefined(); + const map = JSON.parse(fs.readFileSync(`${file}.map`, 'utf8')) as { debugId?: string }; + expect(map.debugId, `${file}.map does not match its module`).toBe(debugId); + } + + // The IDs are hashed from the compiled source, so the emitted entry must match what the server + // serves right now. That is what lets an upload made in CI symbolicate a running app. + const emittedEntry = modules.find(file => file.endsWith(path.join('app', 'actions', 'public', 'entry.ts'))); + const served = await (await fetch(`${baseURL}/assets/app/actions/public/entry.ts`)).text(); + expect(fs.readFileSync(emittedEntry as string, 'utf8').match(DEBUG_ID)?.[1]).toBe(served.match(DEBUG_ID)?.[1]); + + fs.rmSync(outDir, { recursive: true, force: true }); +}); + +function walk(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const file = path.join(dir, entry.name); + return entry.isDirectory() ? walk(file) : [file]; + }); +} diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts b/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts index 030d75dffb23..3f718ae945b4 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts @@ -23,6 +23,10 @@ const router = createRouter({ path: '/users/:id', component: () => import('../views/UserIdView.vue'), }, + { + path: '/route-provider/:id', + component: () => import('../views/RouteProviderView.vue'), + }, { path: '/users-error/:id', component: () => import('../views/UserIdErrorView.vue'), diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/views/RouteProviderView.vue b/dev-packages/e2e-tests/test-applications/vue-3/src/views/RouteProviderView.vue new file mode 100644 index 000000000000..3f7d4dacf895 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/views/RouteProviderView.vue @@ -0,0 +1,14 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/vue-3/tests/route-provider.test.ts b/dev-packages/e2e-tests/test-applications/vue-3/tests/route-provider.test.ts new file mode 100644 index 000000000000..55e890e7398e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/tests/route-provider.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from '@playwright/test'; + +// The route provider reads the router off the app passed to `Sentry.init`, so this fails if it can't +// find it there. +test('resolves the parameterized route through the route provider', async ({ page }) => { + await page.goto('/route-provider/123'); + + await expect(page.locator('#resolved-route')).toHaveText('/route-provider/:id'); +}); diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index df82e5d1bc93..5cdcc184745e 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -108,7 +108,7 @@ }, "devDependencies": { "@opentelemetry/instrumentation-http": "0.220.0", - "@sentry/conventions": "0.24.0", + "@sentry/conventions": "0.25.0", "@sentry-internal/test-utils": "11.2.0", "@types/amqplib": "^0.10.5", "@types/node-cron": "^3.0.11", diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts index 1d2bd3cd8bfc..2eb452d43386 100644 --- a/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts @@ -1,11 +1,8 @@ import { SDK_VERSION, - SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, } from '@sentry/core'; import { SENTRY_SEGMENT_NAME_SOURCE, @@ -14,6 +11,9 @@ import { SENTRY_SDK_NAME, SENTRY_SDK_VERSION, SENTRY_TRACE_LIFECYCLE, + SENTRY_ENVIRONMENT, + SENTRY_RELEASE, + SENTRY_SDK_INTEGRATIONS, } from '@sentry/conventions/attributes'; import { expect, test } from 'vitest'; import { createRunner } from '../../../../utils/runner'; @@ -68,8 +68,8 @@ test('sends a streamed span envelope with correct spans for a manually started s [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, }, name: 'test-child-span', @@ -92,8 +92,8 @@ test('sends a streamed span envelope with correct spans for a manually started s [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, }, links: [ @@ -129,8 +129,8 @@ test('sends a streamed span envelope with correct spans for a manually started s [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, }, name: 'test-manual-span', @@ -150,14 +150,14 @@ test('sends a streamed span envelope with correct spans for a manually started s [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, [SENTRY_SDK_NAME]: { type: 'string', value: EXPECTED_SDK_NAME }, [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: { + [SENTRY_SDK_INTEGRATIONS]: { type: 'array', value: expect.arrayContaining(['SpanStreaming']), }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, [SENTRY_SEGMENT_NAME_SOURCE]: { type: 'string', value: 'custom' }, 'process.runtime.engine.name': { type: 'string', value: 'v8' }, diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/instrument-default-lifecycle.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/instrument-default-lifecycle.mjs new file mode 100644 index 000000000000..cc192fb89834 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/instrument-default-lifecycle.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.129/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/instrument.mjs new file mode 100644 index 000000000000..9b15a0c635e8 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/instrument.mjs @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + traceLifecycle: 'static', + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + dataCollection: { genAI: { inputs: false, outputs: false } }, + transport: loggingTransport, + beforeSendTransaction: event => { + // Filter out mock express server transactions + if (event.transaction.includes('/anthropic/v1/')) { + return null; + } + return event; + }, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/scenario-beta-stream-helpers.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/scenario-beta-stream-helpers.mjs new file mode 100644 index 000000000000..49b2be73ce7a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/scenario-beta-stream-helpers.mjs @@ -0,0 +1,92 @@ +import Anthropic from '@anthropic-ai/sdk'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json()); + + app.post('/anthropic/v1/messages', (req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + + const model = req.body.model; + // The response id names the caller, so the test can find the span for each helper. + const id = `msg_${req.body.messages[0].content}`; + const events = [ + { + 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' }, + ]; + 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`, + }); + + await client.beta.messages + .stream({ + model: 'claude-3-haiku-20240307', + max_tokens: 10, + messages: [{ role: 'user', content: 'beta_stream' }], + }) + .finalMessage(); + + // With `runToolsEagerly`, the runner calls `beta.messages.create()` directly and not through + // `beta.messages.stream()`, but it still sends the stream helper header. + const runner = client.beta.messages.toolRunner({ + model: 'claude-3-haiku-20240307', + max_tokens: 10, + messages: [{ role: 'user', content: 'tool_runner_eager' }], + tools: [ + { + name: 'weather', + description: 'Get the weather by city', + input_schema: { type: 'object', properties: { city: { type: 'string' } } }, + run: () => 'sunny', + }, + ], + 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/v0.129/scenario-stream-helper.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/scenario-stream-helper.mjs new file mode 100644 index 000000000000..7ad1f0395fbf --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/scenario-stream-helper.mjs @@ -0,0 +1,76 @@ +import Anthropic from '@anthropic-ai/sdk'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json()); + + app.post('/anthropic/v1/messages', (req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + + const model = req.body.model; + const events = [ + { + type: 'message_start', + message: { + id: 'msg_stream_1', + 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' }, + ]; + 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`, + }); + + // The stream helper delegates to `messages.create({ stream: true })` underneath. + await client.messages + .stream({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'Stream this please' }], + }) + .finalMessage(); + }); + + await Sentry.flush(2000); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/test.ts new file mode 100644 index 000000000000..04f9cf75d98a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/test.ts @@ -0,0 +1,74 @@ +import { GEN_AI_RESPONSE_ID, GEN_AI_RESPONSE_STREAMING } from '@sentry/conventions/attributes'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +// The attribute extraction is version-independent and covered by the suite pinned to 0.63. What a +// newer SDK puts at risk is how its internals are recognised: since 0.106 the `messages.stream()` +// helper tags its internal `create` call with a lowercase `x-stainless-helper-method` header (it +// was `X-Stainless-Helper-Method` up to 0.105), and the dedup of that call keyed on the exact casing. +describe('Anthropic integration (0.129)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario-stream-helper.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('emits one span for messages.stream(), not a second one for its internal create', async () => { + await createRunner() + .expect({ transaction: { transaction: 'main' } }) + .expect({ + span: container => { + const streamingSpans = container.items.filter( + span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'msg_stream_1', + ); + expect(streamingSpans).toHaveLength(1); + expect(streamingSpans[0]!.attributes['sentry.op']).toEqual({ type: 'string', value: 'gen_ai.chat' }); + expect(streamingSpans[0]!.attributes[GEN_AI_RESPONSE_STREAMING]).toEqual({ + type: 'boolean', + value: true, + }); + }, + }) + .start() + .completed(); + }); + }, + { + additionalDependencies: { + '@anthropic-ai/sdk': '0.129.0', + }, + }, + ); + + // These helpers send the same header, but no `messages-stream` span covers them. So their + // internal `create` must still get a span. + createEsmAndCjsTests( + __dirname, + 'scenario-beta-stream-helpers.mjs', + 'instrument-default-lifecycle.mjs', + (createRunner, test) => { + test('emits one span each for beta.messages.stream() and the eager streaming tool runner', async () => { + await createRunner() + .expect({ + span: container => { + for (const id of ['msg_beta_stream', 'msg_tool_runner_eager']) { + const spans = container.items.filter(span => span.attributes[GEN_AI_RESPONSE_ID]?.value === id); + expect(spans, id).toHaveLength(1); + expect(spans[0]!.attributes['sentry.op']).toEqual({ type: 'string', value: 'gen_ai.chat' }); + } + }, + }) + .start() + .completed(); + }); + }, + { + additionalDependencies: { + '@anthropic-ai/sdk': '0.129.0', + }, + }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/fastify/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/fastify/scenario.mjs index fa654dd7cfb6..83870c07569d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/fastify/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/fastify/scenario.mjs @@ -26,6 +26,15 @@ app.get('/test-exception/:id', async request => { throw new Error(`This is an exception with id ${request.params.id}`); }); +app.post('/test-body', async () => { + return {}; +}); + +app.get('/flush', async () => { + await Sentry.flush(); + return {}; +}); + app.get('/test-inbound-headers/:id', async request => { return { headers: request.headers, id: request.params.id }; }); diff --git a/dev-packages/node-integration-tests/suites/tracing/fastify/test.ts b/dev-packages/node-integration-tests/suites/tracing/fastify/test.ts index 7ace423c9745..aa54d6195b7e 100644 --- a/dev-packages/node-integration-tests/suites/tracing/fastify/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/fastify/test.ts @@ -182,5 +182,31 @@ describe('fastify v5 auto-instrumentation', () => { runner.makeRequest('get', '/test-exception/456', { expectError: true }); await runner.completed(); }); + + test('does not capture 4xx errors raised before the reply status is set', async () => { + const runner = createRunner() + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'This is an exception with id 789', + }, + ], + }, + }, + }) + .start(); + // Fastify rejects an empty JSON body with a 400 and runs `onError` hooks before setting that status. + await runner.makeRequest('post', '/test-body', { + headers: { 'content-type': 'application/json' }, + expectError: true, + }); + // Flush so an event for the 400 would be sent before the exception below, and fail the test. + await runner.makeRequest('get', '/flush'); + await runner.makeRequest('get', '/test-exception/789', { expectError: true }); + await runner.completed(); + }); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts index afd5cbe927a6..3cbe232bb061 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts @@ -172,6 +172,27 @@ describe('httpIntegration', () => { runner.makeRequest('post', '/test?a=1&b=2#hash', { data: 'test body' }); await runner.completed(); }); + + test('prefers the forwarded client address, without the socket port', async () => { + const runner = createRunner() + .expect({ + transaction: transaction => { + const data = transaction.contexts?.trace?.data; + expect(data).toEqual( + expect.objectContaining({ + 'client.address': '203.0.113.7', + 'network.peer.address': '::1', + 'network.peer.port': expect.any(Number), + }), + ); + expect(data).not.toHaveProperty('client.port'); + }, + }) + .start(); + + runner.makeRequest('get', '/test', { headers: { 'X-Forwarded-For': '203.0.113.7, 10.0.0.1' } }); + await runner.completed(); + }); }); // Deno: the requests sometimes get a 500 response when `server.emit` is overwritten. diff --git a/dev-packages/test-utils/src/cli.ts b/dev-packages/test-utils/src/cli.ts index 1feffa3761f2..7e0d92dded70 100644 --- a/dev-packages/test-utils/src/cli.ts +++ b/dev-packages/test-utils/src/cli.ts @@ -8,12 +8,14 @@ import type { SpawnSyncReturns } from 'node:child_process'; export const EVENT_POLLING_OPTIONS = { timeout: 180_000, intervals: [5_000] }; /** - * A node of the span tree returned by `sentry trace view`. Spans, errors and occurrences all + * A node of the span tree returned by the 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; + /** On a span this is the span id of its parent span. */ + parent_span_id?: string | null; event_type?: 'span' | 'error' | 'occurrence' | 'uptime_check'; op?: string | null; /** On a span this is the span name. */ @@ -41,6 +43,9 @@ function runSentryCli(args: string[]): SpawnSyncReturns { // over an env token, so force the env token for identical behaviour everywhere. SENTRY_AUTH_TOKEN: process.env['E2E_TEST_AUTH_TOKEN'], SENTRY_FORCE_ENV_TOKEN: '1', + // Every call polls for data that is still arriving. `sentry api` has no `--fresh` flag and would + // otherwise answer every poll from the cached response of the first one. + SENTRY_NO_CACHE: '1', }, }); @@ -54,37 +59,49 @@ function runSentryCli(args: string[]): SpawnSyncReturns { return result; } +/** + * The API rate limit is shared by every GET request of the E2E token's user, so concurrent jobs can + * exhaust it. The callers poll anyway, so a rate limited request is treated like data that has not + * landed yet instead of failing the test. + */ +function isRateLimited(result: SpawnSyncReturns): boolean { + return /\b429\b|too frequently/i.test(`${result.stdout}${result.stderr}`); +} + /** * Fetch a trace of the E2E test project through the `sentry` CLI, which the calling test app has to * list as a dev dependency. Returns an empty list while the trace has not landed yet. + * + * This calls the trace endpoint directly instead of `sentry trace view --json`, which also looks up + * the project and fetches the details of every span, one request each. That turns every poll into + * dozens of requests and gets the E2E token rate limited. */ export function fetchTrace(traceId: string): TraceItem[] { - const target = traceTarget(traceId); - const result = runSentryCli(['trace', 'view', target, '--json', '--fresh']); + const params = new URLSearchParams({ project: '-1', statsPeriod: '1h', limit: '10000' }); + const path = `/organizations/${process.env['E2E_TEST_SENTRY_ORG_SLUG']}/trace/${traceId}/?${params}`; + const result = runSentryCli(['api', path]); if (result.status === 0) { - return (JSON.parse(result.stdout) as { spans?: TraceItem[] }).spans ?? []; + return JSON.parse(result.stdout) as TraceItem[]; } - // Exit codes 10-19 are auth errors, and a rejected token also surfaces as an API error (exit 30) - // with a 401 in the message. Neither resolves by waiting, so fail loudly instead of polling until - // the timeout and reporting it as a missing event. The trace endpoint is org scoped, so the token - // needs `org:read` on top of the project scopes. + if (isRateLimited(result)) { + return []; + } + + // Exit codes 10-19 are auth errors, and a rejected token also surfaces as an API error with a 401 + // or 403 in the message. Neither resolves by waiting, so fail loudly instead of polling until the + // timeout and reporting it as a missing event. The trace endpoint is org scoped, so the token needs + // `org:read` on top of the project scopes. const isAuthError = result.status !== null && result.status >= 10 && result.status < 20; - if (isAuthError || /\b40[13]\b/.test(result.stderr)) { + if (isAuthError || /\b40[13]\b/.test(`${result.stdout}${result.stderr}`)) { throw new Error( - `sentry trace view ${target} failed with exit code ${result.status}: ${result.stderr}` + + `sentry api ${path} failed with exit code ${result.status}: ${result.stdout}${result.stderr}` + 'E2E_TEST_AUTH_TOKEN needs the `org:read` scope.', ); } - const traceMissing = result.status === 23 && result.stderr.includes(`Trace '${traceId}' not found`); - - if (traceMissing) { - return []; - } - - throw new Error(`sentry trace view ${target} exited with ${result.status}: ${result.stderr}`); + throw new Error(`sentry api ${path} exited with ${result.status}: ${result.stdout}${result.stderr}`); } /** @@ -105,7 +122,62 @@ export function fetchSpanAttributes(traceId: string, spanId: string): Record [name, value])); } - if (result.stdout.includes('"Not found."')) { + if (result.stdout.includes('"Not found."') || isRateLimited(result)) { + return undefined; + } + + throw new Error(`sentry api ${path} exited with ${result.status}: ${result.stdout}${result.stderr}`); +} + +/** An error event in the shape the Sentry API returns it. The exception is the entry of type `exception`. */ +export interface ApiEvent { + entries: { + type: string; + data: { values?: { type?: string; value?: string; mechanism?: { type?: string; handled?: boolean } }[] }; + }[]; +} + +/** Fetch an error event of the E2E test project. Returns `undefined` while the event is not stored yet. */ +export function fetchEvent(eventId: string): ApiEvent | undefined { + const path = + `/projects/${process.env['E2E_TEST_SENTRY_ORG_SLUG']}/${process.env['E2E_TEST_SENTRY_PROJECT']}` + + `/events/${eventId}/`; + const result = runSentryCli(['api', path]); + + if (result.status === 0) { + return JSON.parse(result.stdout) as ApiEvent; + } + + if (result.stdout.includes('"Event not found"') || isRateLimited(result)) { + return undefined; + } + + throw new Error(`sentry api ${path} exited with ${result.status}: ${result.stdout}${result.stderr}`); +} + +/** + * Search the spans of the E2E test project from the last hour, and return the trace id of a span that + * matches `query`. `query` uses the Sentry search syntax, for example `gen_ai.conversation.id:abc`. + * Returns `undefined` while no span matches. + * + * `sentry span list --json` cannot be used for this: it returns no trace id for a project search. + */ +export function findTraceIdOfSpan(query: string): string | undefined { + const params = new URLSearchParams({ + dataset: 'spans', + field: 'trace', + query: `project:${process.env['E2E_TEST_SENTRY_PROJECT']} ${query}`, + statsPeriod: '1h', + per_page: '1', + }); + const path = `/organizations/${process.env['E2E_TEST_SENTRY_ORG_SLUG']}/events/?${params}`; + const result = runSentryCli(['api', path]); + + if (result.status === 0) { + return (JSON.parse(result.stdout) as { data: { trace: string }[] }).data[0]?.trace; + } + + if (isRateLimited(result)) { return undefined; } diff --git a/dev-packages/test-utils/src/cloudflare.ts b/dev-packages/test-utils/src/cloudflare.ts index a85f5246b435..32b411139773 100644 --- a/dev-packages/test-utils/src/cloudflare.ts +++ b/dev-packages/test-utils/src/cloudflare.ts @@ -12,8 +12,8 @@ export interface WorkerGlobalSetupOptions { */ workerPrefix: string; /** - * The secrets of the Worker besides `E2E_TEST_DSN`, as a map from the binding name to the name of the - * environment variable that holds the value. + * The secrets of the Worker besides `E2E_TEST_DSN` and `E2E_TEST_WORKER_TOKEN`, as a map from the + * binding name to the name of the environment variable that holds the value. */ secrets?: Record; } @@ -138,6 +138,11 @@ function getAppDir(config: FullConfig): string { /** * Returns a Playwright global setup that deploys the built test app as a real Worker. The tests read * its URL from `E2E_TEST_WORKER_URL`, and {@link workerGlobalTeardown} deletes it again. + * + * The Worker also gets the secret `E2E_TEST_WORKER_TOKEN`, a random value that is new for every deploy + * and that {@link fetchFromWorker} sends as `Authorization: Bearer `. CI keeps its Workers after + * the run, so a Worker that can spend money (for example with an LLM API key) must reject requests + * without this token. */ export function createWorkerGlobalSetup(options: WorkerGlobalSetupOptions): (config: FullConfig) => Promise { return async config => { @@ -153,8 +158,14 @@ export function createWorkerGlobalSetup(options: WorkerGlobalSetupOptions): (con throw new Error('CLOUDFLARE_ACCOUNT_ID must be set to deploy the test worker.'); } + process.env.E2E_TEST_WORKER_TOKEN = randomBytes(32).toString('hex'); + const secrets: Record = {}; - for (const [binding, envName] of Object.entries({ E2E_TEST_DSN: 'E2E_TEST_DSN', ...options.secrets })) { + for (const [binding, envName] of Object.entries({ + E2E_TEST_DSN: 'E2E_TEST_DSN', + E2E_TEST_WORKER_TOKEN: 'E2E_TEST_WORKER_TOKEN', + ...options.secrets, + })) { const value = process.env[envName]; if (!value) { throw new Error(`${envName} must be set to deploy the test worker.`); @@ -218,14 +229,21 @@ export function workerGlobalTeardown(config: FullConfig): void { * Workers Logs had no invocation for it. `status` is the status the Worker answers with. A Worker * that threw answers with status 500 and Cloudflare error code 1101, which sets it apart from a 500 * that did not come from the Worker. + * + * The request carries the `E2E_TEST_WORKER_TOKEN` of {@link createWorkerGlobalSetup}, unless `init` + * sets its own `Authorization` header. */ export async function fetchFromWorker(url: string, status: number, init?: RequestInit): Promise { const deadline = Date.now() + 60_000; let lastAnswer = 'no answer'; + const headers = new Headers(init?.headers); + if (process.env.E2E_TEST_WORKER_TOKEN && !headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${process.env.E2E_TEST_WORKER_TOKEN}`); + } while (Date.now() < deadline) { try { - const response = await fetch(url, init); + const response = await fetch(url, { ...init, headers }); const body = await response.text(); // Cloudflare sends its error page as HTML to some clients (Node's fetch among them) and as // `error code: ` plain text to others, so the code is read from either format. diff --git a/packages/angular/package.json b/packages/angular/package.json index 284741e566f6..09cfab24f655 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -23,7 +23,7 @@ "dependencies": { "@sentry/browser": "11.2.0", "@sentry/core": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "tslib": "^2.4.1" }, "devDependencies": { diff --git a/packages/astro/package.json b/packages/astro/package.json index 8fa161700399..4da67d9aca52 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -53,7 +53,7 @@ "dependencies": { "@sentry/browser": "11.2.0", "@sentry/core": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/node": "11.2.0", "@sentry/server-runtime-injection": "11.2.0", "@sentry/server-utils": "11.2.0", diff --git a/packages/astro/src/client/routeProvider.ts b/packages/astro/src/client/routeProvider.ts new file mode 100644 index 000000000000..6aeec7bdfd02 --- /dev/null +++ b/packages/astro/src/client/routeProvider.ts @@ -0,0 +1,40 @@ +import type { RouteProvider } from '@sentry/browser'; +import { WINDOW } from '@sentry/browser'; + +/** + * Reads the parameterized route the Astro middleware injects into the document it rendered. + */ +function readRouteNameFromMeta(): string | undefined { + const optionalDocument = WINDOW.document as (typeof WINDOW)['document'] | undefined; + const content = optionalDocument?.querySelector('meta[name=sentry-route-name]')?.getAttribute('content'); + if (!content) { + return undefined; + } + + try { + return decodeURIComponent(content); + } catch { + // The middleware encodes the route, so a value we can't decode isn't one we put there. + return undefined; + } +} + +/** + * A route provider backed by the `sentry-route-name` meta tag the Astro middleware injects. + * + * Unlike a manifest-backed provider this is not a matcher: the document only ever describes the page + * it rendered, so a URL other than the current one resolves to `undefined` rather than a guess. + * + * The tag does track client-side navigations. Astro's `ClientRouter` swaps it during + * `astro:after-swap`, at the same moment `location` changes, so reading it per call stays correct + * across soft navigations and back/forward. It is only stale *during* a navigation, before the swap, + * which is why `resolveRoute` refuses to answer for anything but the current path. + */ +export function createAstroRouteProvider(): RouteProvider { + const isCurrentPath = (url: { pathname: string }): boolean => url.pathname === WINDOW.location?.pathname; + + return { + resolveRoute: url => (isCurrentPath(url) ? readRouteNameFromMeta() : undefined), + resolveCurrentRoute: readRouteNameFromMeta, + }; +} diff --git a/packages/astro/src/client/sdk.ts b/packages/astro/src/client/sdk.ts index 21c5770f255f..14effd00cacb 100644 --- a/packages/astro/src/client/sdk.ts +++ b/packages/astro/src/client/sdk.ts @@ -3,6 +3,7 @@ import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as initBr import type { Client, Integration } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; import { browserTracingIntegration } from './browserTracingIntegration'; +import { createAstroRouteProvider } from './routeProvider'; // Tree-shakable guard to remove all code related to tracing declare const __SENTRY_TRACING__: boolean; @@ -15,6 +16,9 @@ declare const __SENTRY_TRACING__: boolean; export function init(options: BrowserOptions): Client | undefined { const opts = { defaultIntegrations: getDefaultIntegrations(options), + // The middleware injects the route into the document, so route parameterization works from `init` on, + // even with tracing disabled. + routeProvider: createAstroRouteProvider(), ...options, }; diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index f63807df9d8c..4116085ffb41 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -2,6 +2,7 @@ import { SENTRY_SEGMENT_NAME_SOURCE, HTTP_ROUTE, + HTTP_REQUEST_METHOD, SENTRY_OP, URL_FRAGMENT, URL_FULL, @@ -19,7 +20,6 @@ import { hasSpanStreamingEnabled, HTTP_SPAN_NAME_FALLBACK, objectify, - SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, spanToJSON, winterCGRequestToRequestData, filterCollectedUrl, @@ -222,7 +222,7 @@ async function instrumentRequestStartHttpServerSpan( [SENTRY_OP]: HTTP_SERVER, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.astro', [SENTRY_SEGMENT_NAME_SOURCE]: source, - [SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: method, + [HTTP_REQUEST_METHOD]: method, // This is here for backwards compatibility, we used to set this here before method, [URL_FULL]: filterCollectedUrl(ctx.url.href), diff --git a/packages/astro/test/client/routeProvider.test.ts b/packages/astro/test/client/routeProvider.test.ts new file mode 100644 index 000000000000..a81080eb37ab --- /dev/null +++ b/packages/astro/test/client/routeProvider.test.ts @@ -0,0 +1,65 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createAstroRouteProvider } from '../../src/client/routeProvider'; + +let originalDocument: unknown; +let originalLocation: unknown; + +/** Mirrors what the Astro middleware injects: an encoded route on a `sentry-route-name` meta tag. */ +function renderPage(pathname: string, routeName: string | undefined): void { + const meta = routeName + ? { getAttribute: (attr: string) => (attr === 'content' ? encodeURIComponent(routeName) : null) } + : null; + + (GLOBAL_OBJ as { document?: unknown }).document = { + querySelector: (selector: string) => (selector === 'meta[name=sentry-route-name]' ? meta : null), + }; + (GLOBAL_OBJ as { location?: unknown }).location = { pathname }; +} + +describe('createAstroRouteProvider', () => { + beforeEach(() => { + originalDocument = (GLOBAL_OBJ as { document?: unknown }).document; + originalLocation = (GLOBAL_OBJ as { location?: unknown }).location; + }); + + afterEach(() => { + (GLOBAL_OBJ as { document?: unknown }).document = originalDocument; + (GLOBAL_OBJ as { location?: unknown }).location = originalLocation; + }); + + it('resolves the current route from the meta tag', () => { + renderPage('/users/1', '/users/[id]'); + + expect(createAstroRouteProvider().resolveCurrentRoute()).toBe('/users/[id]'); + }); + + it('resolves a URL that is the current page', () => { + renderPage('/users/1', '/users/[id]'); + + expect(createAstroRouteProvider().resolveRoute(new URL('https://example.com/users/1'))).toBe('/users/[id]'); + }); + + it('refuses to answer for a URL that is not the current page', () => { + renderPage('/users/1', '/users/[id]'); + + // The document only ever describes the page it rendered, so guessing here would be wrong. This is + // also what keeps a navigation from being named after the route it is leaving. + expect(createAstroRouteProvider().resolveRoute(new URL('https://example.com/posts/hello'))).toBeUndefined(); + }); + + it('follows a client-side navigation, since the meta tag is swapped with the document', () => { + const provider = createAstroRouteProvider(); + renderPage('/users/1', '/users/[id]'); + expect(provider.resolveCurrentRoute()).toBe('/users/[id]'); + + renderPage('/posts/hello', '/posts/[slug]'); + expect(provider.resolveCurrentRoute()).toBe('/posts/[slug]'); + }); + + it('returns undefined when the middleware injected no route', () => { + renderPage('/users/1', undefined); + + expect(createAstroRouteProvider().resolveCurrentRoute()).toBeUndefined(); + }); +}); diff --git a/packages/astro/test/client/sdk.test.ts b/packages/astro/test/client/sdk.test.ts index 9225af26b564..041034f9164e 100644 --- a/packages/astro/test/client/sdk.test.ts +++ b/packages/astro/test/client/sdk.test.ts @@ -92,6 +92,17 @@ describe('Sentry client SDK', () => { }); }); + it('passes the Astro route provider unless the user passed one', () => { + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + expect(browserInit).toHaveBeenLastCalledWith( + expect.objectContaining({ routeProvider: expect.objectContaining({ resolveRoute: expect.any(Function) }) }), + ); + + const routeProvider = { resolveRoute: () => '/custom', resolveCurrentRoute: () => '/custom' }; + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', routeProvider }); + expect(browserInit).toHaveBeenLastCalledWith(expect.objectContaining({ routeProvider })); + }); + it('returns client from init', () => { expect(init({})).not.toBeUndefined(); }); diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index f61ae7a56ff6..88a21e303f10 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -1,4 +1,4 @@ -import { SENTRY_SEGMENT_NAME_SOURCE, URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; +import { HTTP_REQUEST_METHOD, SENTRY_SEGMENT_NAME_SOURCE, URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; import type { Client, Span } from '@sentry/core'; import * as SentryCore from '@sentry/core'; @@ -128,7 +128,7 @@ describe('sentryMiddleware', () => { [URL_FULL]: 'https://mydomain.io/users/123/details', [URL_PATH]: '/users/123/details', [SENTRY_SEGMENT_NAME_SOURCE]: 'route', - [SentryCore.SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: 'GET', + [HTTP_REQUEST_METHOD]: 'GET', 'http.route': '/users/[id]/details', }, name: 'GET /users/[id]/details', @@ -230,7 +230,7 @@ describe('sentryMiddleware', () => { [URL_FULL]: 'http://localhost:1234/a%xx', [URL_PATH]: 'a%xx', [SENTRY_SEGMENT_NAME_SOURCE]: 'url', - [SentryCore.SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: 'GET', + [HTTP_REQUEST_METHOD]: 'GET', }, name: 'GET a%xx', }, diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 4a23683cbe79..f90a28d73af8 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -56,7 +56,7 @@ "access": "public" }, "dependencies": { - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "@sentry/node": "11.2.0", "@sentry/server-runtime-injection": "11.2.0", @@ -65,7 +65,7 @@ }, "devDependencies": { "@types/node": "^18.19.1", - "@vercel/nft": "^1.3.0" + "@vercel/nft": "^1.11.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/browser-utils/package.json b/packages/browser-utils/package.json index 946dfa09567e..257f4e6cfa8d 100644 --- a/packages/browser-utils/package.json +++ b/packages/browser-utils/package.json @@ -34,7 +34,7 @@ }, "dependencies": { "@sentry/core": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "web-vitals": "^6.1.1" }, "scripts": { diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index 7c3b08c40e3b..52da63c3d6b6 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -29,6 +29,16 @@ export { isBotUserAgent } from './isBotUserAgent'; export { getLocationHref } from './getLocationHref'; +export { + createCachedRouteProvider, + createUrlRouteProvider, + getRouteProvider, + resolveCurrentRoute, + resolveRoute, + setRouteProvider, +} from './routing'; +export type { CachedRouteProvider, RouteProvider } from './routing'; + export { userTimingIntegration } from './performance/userTiming'; export { extractNetworkProtocol } from './performance/utils'; diff --git a/packages/browser-utils/src/routing.ts b/packages/browser-utils/src/routing.ts new file mode 100644 index 000000000000..9f58f9b30e43 --- /dev/null +++ b/packages/browser-utils/src/routing.ts @@ -0,0 +1,168 @@ +import type { Client } from '@sentry/core'; +import { debug, getClient, LRUMap, parseStringToURLObject, safeCallback } from '@sentry/core'; +import { DEBUG_BUILD } from './debug-build'; +import { getLocationHref } from './getLocationHref'; + +/** The parts of a URL a route depends on. A `URL` satisfies it. */ +type RouteUrl = Pick; + +/** + * Resolves URLs to low-cardinality route names. + * + * Framework SDKs register one so that everything the SDK names after a route (span names, the scope's + * transaction name, metric and span segment attributes) gets the parameterized route instead of the raw + * URL, without each integration having to reach into the framework's router itself. + */ +export interface RouteProvider { + /** + * Resolves a URL path template for a specific URL, e.g. `/users/42` -> `/users/:id`. + * + * Must return a path template, never a route identifier. Routers that name routes independently of + * their path (Vue Router's `route.name`, Ember's `posts.show`) have to return the matched path + * instead: callers set `url.template` from this, and an identifier is not a template. An SDK that + * wants to name its span after the identifier still can, on the span itself. + * + * Returns `undefined` when the URL matches no known route. Must answer for the URL it is given rather + * than for wherever the router currently is, so that callers can resolve a URL they captured earlier + * (a web vital reported after a soft navigation, for example). + */ + resolveRoute(url: RouteUrl): string | undefined; + + /** + * Resolves the route the app is currently on. + * + * Routers whose location lives in the address bar can delegate to `resolveRoute`, which is what + * {@link createUrlRouteProvider} does. Routers that keep their own location (memory and hash routers) + * have to answer from that location instead: for those, `location.href` is the unchanging shell URL + * and would bucket every route together. + */ + resolveCurrentRoute(): string | undefined; +} + +const CLIENT_ROUTE_PROVIDERS = new WeakMap(); + +/** + * Registers the route provider for a client, replacing any previously registered one. `BrowserClient` + * calls this with its `routeProvider` option. + * + * Prefer the `routeProvider` option where the provider is known at `init`: the pageload span is named + * while `browserTracingIntegration` sets up, so a provider registered after `init` can only rename it + * after the fact. + * + * A client holds one provider. An app running two routers (a framework migration, or a shell plus an + * island) registers twice and the last one wins, so the first router's routes stop resolving. + */ +export function setRouteProvider(provider: RouteProvider, client: Client | undefined = getClient()): void { + if (!client) { + DEBUG_BUILD && debug.warn('Cannot set a route provider without a client.'); + return; + } + + if (DEBUG_BUILD && getRouteProvider(client)) { + debug.warn( + 'A route provider is already registered for this client and will be replaced. Routes only the previous provider knows about will no longer resolve.', + ); + } + + CLIENT_ROUTE_PROVIDERS.set(client, provider); +} + +/** + * Returns the route provider registered for a client. + */ +export function getRouteProvider(client: Client | undefined = getClient()): RouteProvider | undefined { + return client && CLIENT_ROUTE_PROVIDERS.get(client); +} + +/** + * Resolves a URL to a low-cardinality route name, e.g. `/users/42` -> `/users/:id`. + * + * Returns `undefined` when no route provider is registered or the URL matches no route. Callers pick + * their own fallback, because the right one differs: a span name falls back to a low-cardinality + * constant, the scope's transaction name to the raw path. + */ +export function resolveRoute(url: string | URL, client: Client | undefined = getClient()): string | undefined { + const provider = getRouteProvider(client); + if (!provider) { + return undefined; + } + + const urlObject = typeof url === 'string' ? parseLocation(url) : url; + if (!urlObject) { + return undefined; + } + + return safeCallback( + DEBUG_BUILD ? 'Route provider threw while resolving a route:' : '', + () => provider.resolveRoute(urlObject) || undefined, + () => undefined, + ); +} + +/** + * Resolves the route the app is currently on. + * + * Returns `undefined` when no route provider is registered or the current location matches no route. + */ +export function resolveCurrentRoute(client: Client | undefined = getClient()): string | undefined { + const provider = getRouteProvider(client); + + return safeCallback( + DEBUG_BUILD ? 'Route provider threw while resolving a route:' : '', + () => provider?.resolveCurrentRoute() || undefined, + () => undefined, + ); +} + +/** + * Builds a {@link RouteProvider} for a router whose location is the browser's, which covers every + * router except memory and hash routers. + */ +export function createUrlRouteProvider(resolveRouteFromUrl: (url: RouteUrl) => string | undefined): RouteProvider { + return { + resolveRoute: resolveRouteFromUrl, + resolveCurrentRoute: () => { + const urlObject = parseLocation(getLocationHref()); + + return urlObject && resolveRouteFromUrl(urlObject); + }, + }; +} + +/** + * A {@link RouteProvider} that answers from routes it has been told about, rather than by matching. + */ +export interface CachedRouteProvider extends RouteProvider { + /** Records the route name a router reported for a path. Ignores empty values. */ + record(pathname: string | undefined, routeName: string | null | undefined): void; +} + +/** + * Builds a route provider for a router with no usable matcher, which can only report the route it is + * on as it gets there (SvelteKit's `page.route.id`, Solid Router's current matches). + * + * A URL the app has not visited resolves to `undefined`, which includes the first pageload until the + * router reports. Backed by an LRU so a long-lived app visiting many URLs can't grow it without end, + * and so routes that keep being resolved outlive ones passed through once. + */ +export function createCachedRouteProvider(maxEntries: number = 50): CachedRouteProvider { + const routeNames = new LRUMap(maxEntries); + + return { + ...createUrlRouteProvider(url => routeNames.get(url.pathname)), + record(pathname, routeName) { + if (pathname && routeName) { + routeNames.set(pathname, routeName); + } + }, + }; +} + +/** + * Parses up front so providers never have to, and resolves relative locations (which memory routers + * hand around) against the document. + */ +function parseLocation(url: string): RouteUrl | undefined { + // Without a document `getLocationHref()` is empty, which would otherwise parse as `/`. + return url ? parseStringToURLObject(url, getLocationHref() || undefined) : undefined; +} diff --git a/packages/browser-utils/src/web-vitals/emitSpan.ts b/packages/browser-utils/src/web-vitals/emitSpan.ts index 7294b5c8d4a5..ba56bf783f57 100644 --- a/packages/browser-utils/src/web-vitals/emitSpan.ts +++ b/packages/browser-utils/src/web-vitals/emitSpan.ts @@ -3,13 +3,13 @@ import { getClient, getCurrentScope, getRootSpan, - SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, spanToJSON, } from '@sentry/core'; import { startInactiveSpan } from '@sentry/core/browser'; import { + SENTRY_EXCLUSIVE_TIME, BROWSER_NAVIGATION_ID, BROWSER_NAVIGATION_TYPE, SENTRY_REPLAY_ID, @@ -93,7 +93,7 @@ export function _emitWebVitalSpan(options: WebVitalSpanOptions): void { const attributes: SpanAttributes = { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, - [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 0, + [SENTRY_EXCLUSIVE_TIME]: 0, [`browser.web_vital.${metricName}.value`]: value, // oxlint-disable-next-line typescript-eslint/no-deprecated [SENTRY_TRANSACTION]: segmentName, diff --git a/packages/browser-utils/src/web-vitals/spans.ts b/packages/browser-utils/src/web-vitals/spans.ts index 424180427141..69bee44f19a3 100644 --- a/packages/browser-utils/src/web-vitals/spans.ts +++ b/packages/browser-utils/src/web-vitals/spans.ts @@ -7,7 +7,6 @@ import { getClient, getRootSpan, hasSpanStreamingEnabled, - SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, SEMANTIC_ATTRIBUTE_SENTRY_OP, spanToJSON, timestampInSeconds, @@ -31,6 +30,7 @@ import { } from '../instrumentation/performanceObserver'; import type { LargestContentfulPaint, LayoutShift } from './emitSpan'; import { + SENTRY_EXCLUSIVE_TIME, BROWSER_NAVIGATION_TYPE, BROWSER_WEB_VITAL_INP_INTERACTION_TYPE, BROWSER_WEB_VITAL_INP_TARGET, @@ -435,7 +435,7 @@ export function _sendInpSpan( const name = hasSpanStreaming ? componentName || fallbackName : (selector ?? 'Interaction to next paint'); const attributes: SpanAttributes = { - [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry?.duration ?? inpValue, + [SENTRY_EXCLUSIVE_TIME]: entry?.duration ?? inpValue, }; // The span's name and op always have a value, even for an INP without an entry, so they can't diff --git a/packages/browser-utils/test/routing.test.ts b/packages/browser-utils/test/routing.test.ts new file mode 100644 index 000000000000..7c7b749678a9 --- /dev/null +++ b/packages/browser-utils/test/routing.test.ts @@ -0,0 +1,255 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + createCachedRouteProvider, + createUrlRouteProvider, + resolveCurrentRoute, + getRouteProvider, + resolveRoute, + setRouteProvider, +} from '../src/routing'; +import type { RouteProvider } from '../src/routing'; +import { debug, getCurrentScope, GLOBAL_OBJ, setCurrentClient } from '@sentry/core'; +import { getDefaultClientOptions, TestClient } from './utils/TestClient'; + +function setLocationHref(href: string): void { + (GLOBAL_OBJ as { document?: unknown }).document = { location: { href } }; +} + +function makeClient(): TestClient { + const client = new TestClient(getDefaultClientOptions({ dsn: 'https://public@dsn.ingest.sentry.io/1337' })); + setCurrentClient(client); + client.init(); + + return client; +} + +describe('routing', () => { + let client: TestClient; + + beforeEach(() => { + client = makeClient(); + setLocationHref('https://example.com/users/42?q=1#frag'); + }); + + afterEach(() => { + delete (GLOBAL_OBJ as { document?: unknown }).document; + vi.restoreAllMocks(); + }); + + describe('without a registered provider', () => { + it('returns undefined rather than falling back to the raw path', () => { + expect(resolveRoute('https://example.com/users/42')).toBeUndefined(); + expect(resolveCurrentRoute()).toBeUndefined(); + expect(getRouteProvider()).toBeUndefined(); + }); + }); + + describe('resolveRoute', () => { + it('hands the provider a parsed URL so it never has to parse itself', () => { + const resolveSpy = vi.fn().mockReturnValue('/users/:id'); + setRouteProvider({ resolveRoute: resolveSpy, resolveCurrentRoute: () => undefined }); + + expect(resolveRoute('https://example.com/users/42?q=1')).toBe('/users/:id'); + expect(resolveSpy).toHaveBeenCalledWith(new URL('https://example.com/users/42?q=1')); + }); + + it('accepts a URL object as-is', () => { + const url = new URL('https://example.com/users/42'); + const resolveSpy = vi.fn().mockReturnValue('/users/:id'); + setRouteProvider({ resolveRoute: resolveSpy, resolveCurrentRoute: () => undefined }); + + expect(resolveRoute(url)).toBe('/users/:id'); + expect(resolveSpy).toHaveBeenCalledWith(url); + }); + + it('resolves a relative location against the document, which memory routers rely on', () => { + const resolveSpy = vi.fn().mockReturnValue('/users/:id'); + setRouteProvider({ resolveRoute: resolveSpy, resolveCurrentRoute: () => undefined }); + + resolveRoute('7'); + + expect(resolveSpy).toHaveBeenCalledWith(expect.objectContaining({ pathname: '/users/7', search: '', hash: '' })); + }); + + it('resolves a URL the router has already navigated away from', () => { + setRouteProvider({ + resolveRoute: url => (url.pathname.startsWith('/posts/') ? '/posts/:slug' : undefined), + resolveCurrentRoute: () => '/users/:id', + }); + + expect(resolveRoute('https://example.com/posts/hello')).toBe('/posts/:slug'); + expect(resolveCurrentRoute()).toBe('/users/:id'); + }); + + it('returns undefined for an unparseable URL without calling the provider', () => { + const resolveSpy = vi.fn(); + setRouteProvider({ resolveRoute: resolveSpy, resolveCurrentRoute: () => undefined }); + + expect(resolveRoute('http://')).toBeUndefined(); + expect(resolveSpy).not.toHaveBeenCalled(); + }); + + it('normalizes an empty route name to undefined', () => { + setRouteProvider({ resolveRoute: () => '', resolveCurrentRoute: () => '' }); + + expect(resolveRoute('https://example.com/users/42')).toBeUndefined(); + expect(resolveCurrentRoute()).toBeUndefined(); + }); + }); + + describe('provider errors', () => { + it('swallows a throwing provider instead of taking down the caller', () => { + setRouteProvider({ + resolveRoute: () => { + throw new Error('router blew up'); + }, + resolveCurrentRoute: () => { + throw new Error('router blew up'); + }, + }); + + expect(resolveRoute('https://example.com/users/42')).toBeUndefined(); + expect(resolveCurrentRoute()).toBeUndefined(); + }); + }); + + describe('setRouteProvider', () => { + it('scopes the provider to its client', () => { + const otherClient = new TestClient(getDefaultClientOptions()); + setRouteProvider({ resolveRoute: () => '/users/:id', resolveCurrentRoute: () => '/users/:id' }, client); + + expect(resolveCurrentRoute(client)).toBe('/users/:id'); + expect(resolveCurrentRoute(otherClient)).toBeUndefined(); + }); + + it('replaces a previously registered provider and warns', () => { + const warnSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + setRouteProvider({ resolveRoute: () => '/first', resolveCurrentRoute: () => '/first' }); + setRouteProvider({ resolveRoute: () => '/second', resolveCurrentRoute: () => '/second' }); + + expect(resolveCurrentRoute()).toBe('/second'); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it('does not warn when registering the first provider', () => { + const warnSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + setRouteProvider({ resolveRoute: () => '/first', resolveCurrentRoute: () => '/first' }); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('warns per client rather than globally', () => { + const otherClient = makeClient(); + const warnSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + const provider = { resolveRoute: () => '/users/:id', resolveCurrentRoute: () => '/users/:id' }; + + setRouteProvider(provider, client); + setRouteProvider(provider, otherClient); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('is a no-op when there is no current client', () => { + getCurrentScope().setClient(undefined); + const provider: RouteProvider = { resolveRoute: () => '/users/:id', resolveCurrentRoute: () => '/users/:id' }; + + expect(() => setRouteProvider(provider)).not.toThrow(); + expect(getRouteProvider()).toBeUndefined(); + expect(resolveCurrentRoute()).toBeUndefined(); + }); + }); + + describe('createCachedRouteProvider', () => { + it('resolves a path the router has reported', () => { + const provider = createCachedRouteProvider(); + provider.record('/users/42', '/users/:id'); + setRouteProvider(provider); + + expect(resolveRoute('https://example.com/users/42')).toBe('/users/:id'); + expect(resolveCurrentRoute()).toBe('/users/:id'); + }); + + it('returns undefined for a path the router has not reported yet', () => { + const provider = createCachedRouteProvider(); + setRouteProvider(provider); + + expect(resolveRoute('https://example.com/users/42')).toBeUndefined(); + }); + + it('keeps other routes when re-recording one into a full cache', () => { + const provider = createCachedRouteProvider(2); + provider.record('/users/42', '/users/:id'); + provider.record('/posts/hello', '/posts/:slug'); + + provider.record('/posts/hello', '/posts/:slug'); + + expect(provider.resolveRoute(new URL('https://example.com/posts/hello'))).toBe('/posts/:slug'); + expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBe('/users/:id'); + }); + + it('keeps resolving a URL the router has navigated away from', () => { + const provider = createCachedRouteProvider(); + provider.record('/posts/hello', '/posts/:slug'); + provider.record('/users/42', '/users/:id'); + setRouteProvider(provider); + + expect(resolveRoute('https://example.com/posts/hello')).toBe('/posts/:slug'); + }); + + it('ignores empty paths and route names', () => { + const provider = createCachedRouteProvider(); + provider.record(undefined, '/users/:id'); + provider.record('/users/42', null); + setRouteProvider(provider); + + expect(resolveRoute('https://example.com/users/42')).toBeUndefined(); + }); + + it('evicts the oldest entry once the cache is full', () => { + const provider = createCachedRouteProvider(2); + provider.record('/a', '/a'); + provider.record('/b', '/b'); + provider.record('/c', '/c'); + setRouteProvider(provider); + + expect(resolveRoute('https://example.com/a')).toBeUndefined(); + expect(resolveRoute('https://example.com/b')).toBe('/b'); + expect(resolveRoute('https://example.com/c')).toBe('/c'); + }); + + it('keeps a recently resolved path alive past newer entries', () => { + const provider = createCachedRouteProvider(2); + provider.record('/a', '/a'); + provider.record('/b', '/b'); + setRouteProvider(provider); + + // Resolving `/a` makes it the most recently used, so `/b` is evicted instead. + expect(resolveRoute('https://example.com/a')).toBe('/a'); + provider.record('/c', '/c'); + + expect(resolveRoute('https://example.com/a')).toBe('/a'); + expect(resolveRoute('https://example.com/b')).toBeUndefined(); + }); + }); + + describe('createUrlRouteProvider', () => { + it('derives the current route from the document location', () => { + setRouteProvider(createUrlRouteProvider(url => (url.pathname === '/users/42' ? '/users/:id' : undefined))); + + expect(resolveCurrentRoute()).toBe('/users/:id'); + }); + + it('returns undefined when the current location matches no route', () => { + setRouteProvider(createUrlRouteProvider(() => undefined)); + + expect(resolveCurrentRoute()).toBeUndefined(); + }); + + it('returns undefined when there is no document location to read', () => { + delete (GLOBAL_OBJ as { document?: unknown }).document; + setRouteProvider(createUrlRouteProvider(() => '/users/:id')); + + expect(resolveCurrentRoute()).toBeUndefined(); + }); + }); +}); diff --git a/packages/browser/package.json b/packages/browser/package.json index a86649cfa040..40b01b363e36 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -39,7 +39,7 @@ }, "dependencies": { "@sentry/browser-utils": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/feedback": "11.2.0", "@sentry/replay": "11.2.0", "@sentry/replay-canvas": "11.2.0", diff --git a/packages/browser/src/client.ts b/packages/browser/src/client.ts index 0221ca2d30ce..e7777cc89d8a 100644 --- a/packages/browser/src/client.ts +++ b/packages/browser/src/client.ts @@ -8,6 +8,8 @@ import type { SeverityLevel, } from '@sentry/core'; import type { BrowserClientReplayOptions } from '@sentry/core/browser'; +import type { RouteProvider } from '@sentry/browser-utils'; +import { setRouteProvider } from '@sentry/browser-utils'; import { addAutoIpAddressToSession, applySdkMetadata, Client, getSDKSource } from '@sentry/core'; import { eventFromException, eventFromMessage } from './eventbuilder'; import { WINDOW } from './helpers'; @@ -76,6 +78,14 @@ type BrowserSpecificOptions = BrowserClientReplayOptions & * IMPORTANT: Only set this option to `true` while developing, not in production! */ spotlight?: boolean | string; + + /** + * Resolves URLs to low-cardinality route names, e.g. `/users/42` -> `/users/:id`. + * + * Framework SDKs set this for you. Set it yourself to name routes for a router the SDK has no + * integration for. `setRouteProvider` replaces it at runtime. + */ + routeProvider?: RouteProvider; }; /** * Configuration options for the Sentry Browser SDK. @@ -108,6 +118,10 @@ export class BrowserClient extends Client { super(opts); + if (opts.routeProvider) { + setRouteProvider(opts.routeProvider, this); + } + // Unhandled errors don't actually crash the browser, so we report `unhandled` rather than `crashed`. this._unhandledSessionStatus = 'unhandled'; diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index d6ee777fb402..dc8bde1ec231 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -40,6 +40,15 @@ export { fetchStreamPerformanceIntegration } from './integrations/fetchStreamPer export { webVitalsIntegration } from './integrations/webVitals'; export { userTimingIntegration } from './integrations/usertiming'; export { bfcacheMetricsIntegration } from './integrations/bfcacheMetrics'; +export { + createCachedRouteProvider, + createUrlRouteProvider, + getRouteProvider, + resolveCurrentRoute, + resolveRoute, + setRouteProvider, +} from '@sentry/browser-utils'; +export type { CachedRouteProvider, RouteProvider } from '@sentry/browser-utils'; export { interactionsIntegration } from './integrations/interactions'; export type { RequestInstrumentationOptions } from './tracing/request'; diff --git a/packages/browser/src/integrations/bfcacheMetrics.ts b/packages/browser/src/integrations/bfcacheMetrics.ts index 5dd076c2783a..78610dc3b2a2 100644 --- a/packages/browser/src/integrations/bfcacheMetrics.ts +++ b/packages/browser/src/integrations/bfcacheMetrics.ts @@ -8,6 +8,7 @@ import { } from '@sentry/conventions/attributes'; import type { IntegrationFn, SpanAttributes } from '@sentry/core'; import { debug, defineIntegration, getCurrentScope, metrics } from '@sentry/core'; +import { resolveCurrentRoute } from '@sentry/browser-utils'; import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../helpers'; @@ -129,14 +130,20 @@ function _captureBFCacheReason({ reason, frame }: CollectedReason, routeName?: s } /** - * The segment name for a bfcache navigation, read from the scope rather than any span. + * The segment name for a bfcache navigation. * - * A hit restore is silent to tracing (no pageload span), but the frozen scope still holds the last - * transaction name a downstream SDK (Vue/React/etc.) set before the freeze, so we reuse that. On a miss the - * page reloads with a fresh scope, so this is the new pageload name. Falls back to the raw pathname when unset. + * A registered route provider is preferred because it is parameterized, which matters more here than + * elsewhere: this ends up as a metric dimension, where a raw URL is unbounded cardinality. + * + * Without one we fall back to the scope. A hit restore is silent to tracing (no pageload span), but the + * frozen scope still holds the last transaction name a downstream SDK (Vue/React/etc.) set before the + * freeze, so we reuse that. On a miss the page reloads with a fresh scope, so this is the new pageload + * name. Falls back to the raw pathname when unset. + * + * Exported for tests only. */ -function _getSegmentName(): string | undefined { - return getCurrentScope().getScopeData().transactionName || WINDOW.location?.pathname; +export function _getSegmentName(): string | undefined { + return resolveCurrentRoute() || getCurrentScope().getScopeData().transactionName || WINDOW.location?.pathname; } /** diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 643ced098f0f..b01bf26f10f7 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -4,7 +4,6 @@ import { hasSpanStreamingEnabled, isObjectLike, isString, - SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, spanToJSON, stringMatchesSomePattern, } from '@sentry/core'; @@ -15,6 +14,7 @@ import { GRAPHQL_OPERATION_NAME, GRAPHQL_OPERATION_TYPE, HTTP_METHOD, + HTTP_REQUEST_METHOD, SENTRY_OP, URL_FULL, } from '@sentry/conventions/attributes'; @@ -77,7 +77,7 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption const httpUrl = spanAttributes[URL_FULL]; // oxlint-disable-next-line typescript/no-deprecated - const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes[HTTP_METHOD]; + const httpMethod = spanAttributes[HTTP_REQUEST_METHOD] || spanAttributes[HTTP_METHOD]; if (!isString(httpUrl) || !isString(httpMethod)) { return; diff --git a/packages/browser/src/tracing/linkedTraces.ts b/packages/browser/src/tracing/linkedTraces.ts index 178305da0016..741212cf2065 100644 --- a/packages/browser/src/tracing/linkedTraces.ts +++ b/packages/browser/src/tracing/linkedTraces.ts @@ -5,12 +5,11 @@ import { getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE, spanToJSON, } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../exports'; -import { SENTRY_OP } from '@sentry/conventions/attributes'; +import { SENTRY_LINK_TYPE, SENTRY_OP } from '@sentry/conventions/attributes'; export interface PreviousTraceInfo { /** @@ -188,7 +187,7 @@ export function addPreviousTraceSpanLink( span.addLink({ context: previousTraceSpanCtx, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }); diff --git a/packages/browser/test/client.test.ts b/packages/browser/test/client.test.ts index cc7a6cbbafc9..eb73cb98051a 100644 --- a/packages/browser/test/client.test.ts +++ b/packages/browser/test/client.test.ts @@ -2,7 +2,8 @@ * @vitest-environment jsdom */ -import { getCurrentScope, makeSession, setCurrentClient } from '@sentry/core'; +import { getRouteProvider, resolveCurrentRoute, setRouteProvider } from '@sentry/browser-utils'; +import { debug, getCurrentScope, makeSession, setCurrentClient } from '@sentry/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { applyDefaultOptions, BrowserClient } from '../src/client'; import { WINDOW } from '../src/helpers'; @@ -51,6 +52,28 @@ describe('BrowserClient', () => { expect(flushSpy).toHaveBeenCalledTimes(1); }); + describe('routeProvider option', () => { + it('registers the provider for the client', () => { + const routeProvider = { resolveRoute: () => '/users/:id', resolveCurrentRoute: () => '/users/:id' }; + client = new BrowserClient(getDefaultBrowserClientOptions({ routeProvider })); + + expect(getRouteProvider(client)).toBe(routeProvider); + }); + + it('is replaced by a provider registered at runtime', () => { + vi.spyOn(debug, 'warn').mockImplementation(() => {}); + client = new BrowserClient( + getDefaultBrowserClientOptions({ + routeProvider: { resolveRoute: () => '/option', resolveCurrentRoute: () => '/option' }, + }), + ); + + setRouteProvider({ resolveRoute: () => '/runtime', resolveCurrentRoute: () => '/runtime' }, client); + + expect(resolveCurrentRoute(client)).toBe('/runtime'); + }); + }); + describe('session status on unhandled errors', () => { afterEach(() => { getCurrentScope().setSession(undefined); diff --git a/packages/browser/test/integrations/bfcacheMetrics.test.ts b/packages/browser/test/integrations/bfcacheMetrics.test.ts index 42b51dd58d14..74ec02ea9a66 100644 --- a/packages/browser/test/integrations/bfcacheMetrics.test.ts +++ b/packages/browser/test/integrations/bfcacheMetrics.test.ts @@ -1,8 +1,53 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { debug } from '@sentry/core'; -import { _collectNotRestoredReasons, _resolveMaxReasons } from '../../src/integrations/bfcacheMetrics'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { debug, getCurrentScope, setCurrentClient } from '@sentry/core'; +import { setRouteProvider } from '@sentry/browser-utils'; +import { BrowserClient } from '../../src/client'; +import { _collectNotRestoredReasons, _getSegmentName, _resolveMaxReasons } from '../../src/integrations/bfcacheMetrics'; +import { WINDOW } from '../../src/helpers'; +import { getDefaultBrowserClientOptions } from '../helper/browser-client-options'; describe('bfcacheMetricsIntegration', () => { + describe('_getSegmentName', () => { + beforeEach(() => { + getCurrentScope().setTransactionName(undefined); + const client = new BrowserClient(getDefaultBrowserClientOptions()); + setCurrentClient(client); + client.init(); + }); + + afterEach(() => { + delete (WINDOW as { location?: unknown }).location; + getCurrentScope().setTransactionName(undefined); + getCurrentScope().setClient(undefined); + }); + + it('prefers the parameterized route from a registered provider', () => { + getCurrentScope().setTransactionName('/users/42'); + setRouteProvider({ resolveRoute: () => '/users/:id', resolveCurrentRoute: () => '/users/:id' }); + + expect(_getSegmentName()).toBe('/users/:id'); + }); + + it('falls back to the scope when no provider is registered', () => { + getCurrentScope().setTransactionName('/users/:id'); + + expect(_getSegmentName()).toBe('/users/:id'); + }); + + it('falls back to the scope when the provider matches no route', () => { + getCurrentScope().setTransactionName('/users/:id'); + setRouteProvider({ resolveRoute: () => undefined, resolveCurrentRoute: () => undefined }); + + expect(_getSegmentName()).toBe('/users/:id'); + }); + + it('falls back to the raw pathname when nothing else knows the route', () => { + (WINDOW as { location?: unknown }).location = { pathname: '/users/42' }; + + expect(_getSegmentName()).toBe('/users/42'); + }); + }); + describe('_resolveMaxReasons', () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/packages/bun/package.json b/packages/bun/package.json index a8834c56f014..f21a1d8fa017 100644 --- a/packages/bun/package.json +++ b/packages/bun/package.json @@ -43,7 +43,7 @@ }, "dependencies": { "@sentry/core": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/node": "11.2.0", "@sentry/server-utils": "11.2.0" }, diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index 11977876d040..080251c7dba3 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -12,7 +12,6 @@ import { HTTP_SPAN_NAME_FALLBACK, isURLObjectRelative, parseStringToURLObject, - SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, setHttpStatus, startSpan, @@ -21,10 +20,12 @@ import { filterCollectedUrl, filterCollectedUrlQuery, } from '@sentry/core'; +import { getClientIPAddress } from '@sentry/core/server'; import type { Server, ServeOptions } from 'bun'; import { CLIENT_ADDRESS, CLIENT_PORT, + HTTP_REQUEST_METHOD, NETWORK_PROTOCOL_NAME, SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE, @@ -245,30 +246,35 @@ function wrapRequestHandler( const client = getClient(); const dataCollection = client?.getDataCollectionOptions(); - if (dataCollection?.userInfo) { - // `client.address` is the originating client, so a forwarding header wins over the socket, which - // behind a proxy holds the proxy's address. - const forwardedFor = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim(); + let socketAddress: { address: string; port: number } | undefined; + if (dataCollection) { + const headers = request.headers.toJSON(); // Bun passes the `Server` as the second argument to both `fetch` and route handlers, except // when the handler runs through `server.fetch()`. - const socketAddress = getRequestIP(args[1], request); - if (forwardedFor || socketAddress?.address) { - attributes[CLIENT_ADDRESS] = forwardedFor || socketAddress?.address; - } - if (socketAddress?.port) { - attributes[CLIENT_PORT] = socketAddress.port; + socketAddress = getRequestIP(args[1], request); + + if (dataCollection.userInfo) { + // `client.address` is the originating client, so a forwarding header wins over the socket, which + // behind a proxy holds the proxy's address. The socket port is the proxy's too, so `client.port` + // stays unset then. + const forwardedAddress = getClientIPAddress(headers); + if (forwardedAddress) { + attributes[CLIENT_ADDRESS] = forwardedAddress; + } else if (socketAddress) { + attributes[CLIENT_ADDRESS] = socketAddress.address; + attributes[CLIENT_PORT] = socketAddress.port; + } } + + Object.assign(attributes, httpHeadersToSpanAttributes(headers, dataCollection)); } // describes the OSI application-layer protocol (http), not the scheme (might be https) attributes[NETWORK_PROTOCOL_NAME] = 'http'; - if (dataCollection) { - Object.assign(attributes, httpHeadersToSpanAttributes(request.headers.toJSON(), dataCollection)); - } - isolationScope.setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(request), + ipAddress: socketAddress?.address, }); if (client && dataCollection) { @@ -329,12 +335,15 @@ function wrapRequestHandler( }); } -function getRequestIP(server: unknown, request: Request): { address: string; port: number } | undefined { - if (typeof (server as Partial | undefined)?.requestIP !== 'function') { +function getRequestIP( + server: Partial> | undefined, + request: Request, +): { address: string; port: number } | undefined { + if (typeof server?.requestIP !== 'function') { return undefined; } try { - return (server as Server).requestIP(request) ?? undefined; + return server.requestIP(request) ?? undefined; } catch { // Defensive: never let a failed lookup break the user's handler. return undefined; @@ -347,7 +356,7 @@ function getSpanAttributesFromParsedUrl( ): SpanAttributes { const attributes: SpanAttributes = { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.bun.serve', - [SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: request.method || 'GET', + [HTTP_REQUEST_METHOD]: request.method || 'GET', [SENTRY_SEGMENT_NAME_SOURCE]: 'url', }; diff --git a/packages/bun/test/integrations/bunserver.test.ts b/packages/bun/test/integrations/bunserver.test.ts index d3ed401e095c..391b3c981c59 100644 --- a/packages/bun/test/integrations/bunserver.test.ts +++ b/packages/bun/test/integrations/bunserver.test.ts @@ -570,7 +570,7 @@ describe('Bun Serve Integration', () => { ); }); - test('prefers the first x-forwarded-for address over the socket address', async () => { + test('prefers the first x-forwarded-for address over the socket address, without the socket port', async () => { const server = Bun.serve({ async fetch(_req) { return new Response('Bun!'); @@ -593,6 +593,97 @@ describe('Bun Serve Integration', () => { }), expect.any(Function), ); + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.not.objectContaining({ + 'client.port': expect.anything(), + }), + }), + expect.any(Function), + ); + }); + + test('reads the client address from other forwarding headers', async () => { + const server = Bun.serve({ + async fetch(_req) { + return new Response('Bun!'); + }, + port, + }); + + await fetch(`http://localhost:${port}/`, { + headers: { 'X-Real-IP': '203.0.113.8' }, + }); + + await server.stop(); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'client.address': '203.0.113.8', + }), + }), + expect.any(Function), + ); + }); + + test('falls back to the socket address when the forwarding header is not an IP', async () => { + const server = Bun.serve({ + async fetch(_req) { + return new Response('Bun!'); + }, + port, + }); + + await fetch(`http://localhost:${port}/`, { + headers: { 'X-Forwarded-For': 'unknown' }, + }); + + await server.stop(); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'client.address': expect.stringMatching(/^(127\.0\.0\.1|::1|::ffff:127\.0\.0\.1)$/), + 'client.port': expect.any(Number), + }), + }), + expect.any(Function), + ); + }); + + test('sets the socket address as the user IP on error events', async () => { + const events: SentryCore.Event[] = []; + setupClient({ + integrations: [SentryCore.requestDataIntegration()], + beforeSend: event => { + events.push(event); + return null; + }, + }); + + const server = Bun.serve({ + async fetch(_req) { + SentryCore.captureException(new Error('Boom')); + return new Response('Bun!'); + }, + port, + }); + + await fetch(`http://localhost:${port}/`); + + await server.stop(); + await SentryCore.flush(); + + expect(events).toEqual([ + expect.objectContaining({ + user: expect.objectContaining({ + ip_address: expect.stringMatching(/^(127\.0\.0\.1|::1|::ffff:127\.0\.0\.1)$/), + }), + }), + ]); }); test('does not capture client address when userInfo collection is disabled', async () => { @@ -680,8 +771,14 @@ describe('Bun Serve Integration', () => { await server.stop(); expect(startSpanSpy).toHaveBeenCalledTimes(1); - const attributes = startSpanSpy.mock.calls[0]?.[0]?.attributes; - expect(attributes?.['http.request.header.x-forwarded-for']).toEqual(['203.0.113.7']); + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'http.request.header.x-forwarded-for': ['203.0.113.7'], + }), + }), + expect.any(Function), + ); }); test('filters request headers according to the dataCollection deny list', async () => { @@ -703,9 +800,15 @@ describe('Bun Serve Integration', () => { await server.stop(); expect(startSpanSpy).toHaveBeenCalledTimes(1); - const attributes = startSpanSpy.mock.calls[0]?.[0]?.attributes; - expect(attributes?.['http.request.header.x-internal']).toEqual(['[Filtered]']); - expect(attributes?.['http.request.header.x-public']).toEqual(['public-value']); + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'http.request.header.x-internal': ['[Filtered]'], + 'http.request.header.x-public': ['public-value'], + }), + }), + expect.any(Function), + ); }); test('filters always-sensitive request headers even when collection is permissive', async () => { @@ -725,8 +828,14 @@ describe('Bun Serve Integration', () => { await server.stop(); expect(startSpanSpy).toHaveBeenCalledTimes(1); - const attributes = startSpanSpy.mock.calls[0]?.[0]?.attributes; - expect(attributes?.['http.request.header.authorization']).toEqual(['[Filtered]']); + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'http.request.header.authorization': ['[Filtered]'], + }), + }), + expect.any(Function), + ); }); test('applies the dataCollection response header collection behavior', async () => { @@ -746,9 +855,12 @@ describe('Bun Serve Integration', () => { await server.stop(); expect(setAttributesSpy).toHaveBeenCalledTimes(1); - const responseAttributes = setAttributesSpy.mock.calls[0]?.[0]; - expect(responseAttributes?.['http.response.header.x-internal']).toEqual(['[Filtered]']); - expect(responseAttributes?.['http.response.header.x-public']).toEqual(['public-value']); + expect(setAttributesSpy).toHaveBeenCalledWith( + expect.objectContaining({ + 'http.response.header.x-internal': ['[Filtered]'], + 'http.response.header.x-public': ['public-value'], + }), + ); }); }); diff --git a/packages/bundler-plugins/src/rollup/debug-id-injection.ts b/packages/bundler-plugins/src/rollup/debug-id-injection.ts new file mode 100644 index 000000000000..b9340b98ae05 --- /dev/null +++ b/packages/bundler-plugins/src/rollup/debug-id-injection.ts @@ -0,0 +1,70 @@ +import { stringToUUID } from '../core'; + +export const ROLLDOWN_DEBUG_ID_PLACEHOLDER = 'SENTRY_DEBUG_ID_PLACEHOLDER_00000000'; + +type GeneratedChunk = { + type: 'chunk'; + fileName: string; + code: string; +}; + +type GeneratedAsset = { + type: 'asset'; + fileName: string; +}; + +export type GeneratedBundle = Record; + +const SENTRY_DEBUG_ID_IDENTIFIER = '_sentryDebugIdIdentifier'; +const SENTRY_DEBUG_ID_IDENTIFIER_PREFIX = 'sentry-dbid-'; + +export function hasExistingDebugID(code: string): boolean { + const chunkStartSnippet = code.slice(0, 6000); + const chunkEndSnippet = code.slice(-500); + + return chunkStartSnippet.includes(SENTRY_DEBUG_ID_IDENTIFIER) || chunkEndSnippet.includes('//# debugId='); +} + +export function getDebugIdForChunk(code: string, isRolldown: boolean): string { + return isRolldown ? ROLLDOWN_DEBUG_ID_PLACEHOLDER : stringToUUID(code); +} + +function replaceAt(code: string, start: number, search: string, replacement: string): string { + return `${code.slice(0, start)}${replacement}${code.slice(start + search.length)}`; +} + +export function finalizeRolldownDebugIds(bundle: GeneratedBundle): void { + for (const [fileName, output] of Object.entries(bundle)) { + if (output.type !== 'chunk') { + continue; + } + + const identifier = `${SENTRY_DEBUG_ID_IDENTIFIER_PREFIX}${ROLLDOWN_DEBUG_ID_PLACEHOLDER}`; + const identifierPropertyStart = output.code.indexOf(SENTRY_DEBUG_ID_IDENTIFIER); + const identifierStart = output.code.indexOf( + identifier, + identifierPropertyStart + SENTRY_DEBUG_ID_IDENTIFIER.length, + ); + if (identifierStart === -1) { + continue; + } + + const identifierPlaceholderStart = identifierStart + SENTRY_DEBUG_ID_IDENTIFIER_PREFIX.length; + const debugIdsPlaceholderStart = output.code.lastIndexOf(ROLLDOWN_DEBUG_ID_PLACEHOLDER, identifierStart - 1); + if (debugIdsPlaceholderStart === -1) { + throw new Error(`Failed to locate the Sentry debug ID placeholder for chunk \`${fileName}\`.`); + } + + // The fixed-width replacement happens after Rolldown computes [hash], so the emitted file name + // still matches the placeholder-bearing chunk. Hashing only the code keeps byte-identical chunks + // on one ID, as Rollup builds do. + const debugId = stringToUUID(output.code); + const codeWithIdentifier = replaceAt( + output.code, + identifierPlaceholderStart, + ROLLDOWN_DEBUG_ID_PLACEHOLDER, + debugId, + ); + output.code = replaceAt(codeWithIdentifier, debugIdsPlaceholderStart, ROLLDOWN_DEBUG_ID_PLACEHOLDER, debugId); + } +} diff --git a/packages/bundler-plugins/src/rollup/debug-id-stamping.ts b/packages/bundler-plugins/src/rollup/debug-id-stamping.ts new file mode 100644 index 000000000000..f2d3d959df97 --- /dev/null +++ b/packages/bundler-plugins/src/rollup/debug-id-stamping.ts @@ -0,0 +1,32 @@ +import { isJsFile, stampDebugId } from '../core'; + +// The subset of Rollup's `OutputBundle` the stamping hooks read. +export type OutputBundle = Record< + string, + | { type: 'chunk'; fileName: string; code: string; sourcemapFileName?: string | null } + | { type: 'asset'; fileName: string; source: string | Uint8Array } +>; + +export function stampDebugIds(bundle: OutputBundle, updateChunkCode: boolean): void { + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk' || !isJsFile(output.fileName)) { + continue; + } + + const sourceMapAsset = bundle[output.sourcemapFileName ?? `${output.fileName}.map`]; + const sourceMapSource = + sourceMapAsset?.type === 'asset' && typeof sourceMapAsset.source === 'string' ? sourceMapAsset.source : undefined; + + const stamped = stampDebugId(output.code, sourceMapSource); + if (!stamped) { + continue; + } + + if (updateChunkCode) { + output.code = stamped.bundleSource; + } + if (stamped.sourceMapSource !== undefined && sourceMapAsset?.type === 'asset') { + sourceMapAsset.source = stamped.sourceMapSource; + } + } +} diff --git a/packages/bundler-plugins/src/rollup/index.ts b/packages/bundler-plugins/src/rollup/index.ts index d9bbb517daad..b3cc6861f55e 100644 --- a/packages/bundler-plugins/src/rollup/index.ts +++ b/packages/bundler-plugins/src/rollup/index.ts @@ -6,13 +6,11 @@ import { isJsFile, shouldSkipCodeInjection, getDebugIdSnippet, - stringToUUID, createDebugIdUploadFunction, globFiles, createComponentNameAnnotateHooks, replaceBooleanFlagsInCode, CodeInjection, - stampDebugId, getCodeInjectionPosition, } from '../core'; import type { ComponentAnnotationTransformMeta } from '../core/component-annotation-oxc'; @@ -20,19 +18,14 @@ import type { SourceMap } from 'magic-string'; import MagicString from 'magic-string'; import * as path from 'node:path'; import { createRequire } from 'node:module'; +import { finalizeRolldownDebugIds, getDebugIdForChunk, hasExistingDebugID } from './debug-id-injection'; +import { stampDebugIds, type OutputBundle } from './debug-id-stamping'; // The subset of Rollup's `TransformResult` that this plugin's `transform` // hook actually returns. Defined locally instead of imported from `rollup` // because `rollup` is an optional dependency. type TransformResult = { code: string; map?: SourceMap | string | { mappings: string } | null } | null | undefined; -// The subset of Rollup's `OutputBundle` the stamping hook reads. -type OutputBundle = Record< - string, - | { type: 'chunk'; fileName: string; code: string; sourcemapFileName?: string | null } - | { type: 'asset'; fileName: string; source: string | Uint8Array } ->; - type ViteModule = { parseAstAsync?: (code: string, options: { lang: 'jsx' | 'tsx' }) => Promise; }; @@ -41,19 +34,10 @@ type ViteParseAstAsync = NonNullable; let viteParseAstAsyncPromise: Promise | undefined; -const JS_MODULE_ID_FILTER = /\.[cm]?[jt]sx?(?:[?#].*)?$/; - -function hasExistingDebugID(code: string): boolean { - // Check if a debug ID has already been injected to avoid duplicate injection (e.g. by another plugin or Sentry CLI) - const chunkStartSnippet = code.slice(0, 6000); - const chunkEndSnippet = code.slice(-500); +// Rolldown sets `meta.rolldownVersion` on the plugin context. +type PluginContext = { meta?: { rolldownVersion?: string } }; - if (chunkStartSnippet.includes('_sentryDebugIdIdentifier') || chunkEndSnippet.includes('//# debugId=')) { - return true; // Debug ID already present, skip injection - } - - return false; -} +const JS_MODULE_ID_FILTER = /\.[cm]?[jt]sx?(?:[?#].*)?$/; function getRollupMajorVersion(): string | undefined { try { @@ -195,6 +179,7 @@ export function _rollupPluginInternal( } function renderChunk( + this: PluginContext | undefined, code: string, chunk: { fileName: string; facadeModuleId?: string | null }, _?: unknown, @@ -215,7 +200,7 @@ export function _rollupPluginInternal( const injectCode = staticInjectionCode.clone(); if (sourcemapsEnabled && !hasExistingDebugID(code)) { - const debugId = stringToUUID(code); // generate a deterministic debug ID + const debugId = getDebugIdForChunk(code, !!this?.meta?.rolldownVersion); injectCode.append(getDebugIdSnippet(debugId)); } @@ -242,35 +227,24 @@ export function _rollupPluginInternal( } /** - * Stamps debug IDs into the emitted chunks and source maps. + * Resolves Rolldown's placeholder debug IDs, then stamps debug IDs into the emitted chunks and + * source maps. * * `disable-upload` skips the upload routine (which stamps debug IDs into temp copies), so the emitted * artifacts get stamped here instead. Not in `renderChunk`: minifiers running after it would strip the * comment. Rollup computes `[hash]` file names before this hook, so only plugins that hash the final * assets afterwards (e.g. subresource integrity) see the stamped content. */ - function generateBundle(_outputOptions: unknown, bundle: OutputBundle): void { - for (const output of Object.values(bundle)) { - if (output.type !== 'chunk' || !isJsFile(output.fileName)) { - continue; - } - - const sourceMapAsset = bundle[output.sourcemapFileName ?? `${output.fileName}.map`]; - const sourceMapSource = - sourceMapAsset?.type === 'asset' && typeof sourceMapAsset.source === 'string' - ? sourceMapAsset.source - : undefined; - - const stamped = stampDebugId(output.code, sourceMapSource); - if (!stamped) { - continue; - } + function generateBundle(this: PluginContext | undefined, _outputOptions: unknown, bundle: OutputBundle): void { + if (this?.meta?.rolldownVersion) { + finalizeRolldownDebugIds(bundle); + } - output.code = stamped.bundleSource; - if (stamped.sourceMapSource !== undefined && sourceMapAsset?.type === 'asset') { - sourceMapAsset.source = stamped.sourceMapSource; - } + if (options.sourcemaps?.disable !== 'disable-upload') { + return; } + + stampDebugIds(bundle, true); } async function writeBundle( @@ -320,9 +294,7 @@ export function _rollupPluginInternal( buildStart, ...(shouldTransform ? { transform: transformHook } : {}), renderChunk, - ...(options.sourcemaps?.disable === 'disable-upload' - ? { generateBundle: { order: 'pre' as const, handler: generateBundle } } - : {}), + generateBundle: { order: 'pre' as const, handler: generateBundle }, writeBundle, }; } diff --git a/packages/bundler-plugins/src/vite/index.ts b/packages/bundler-plugins/src/vite/index.ts index ea24f64e5d73..d9b06efd9c25 100644 --- a/packages/bundler-plugins/src/vite/index.ts +++ b/packages/bundler-plugins/src/vite/index.ts @@ -1,10 +1,15 @@ import type { SentryRollupPluginOptions } from '../rollup'; import { _rollupPluginInternal } from '../rollup'; import { createRequire } from 'node:module'; +import { stampDebugIds, type OutputBundle } from '../rollup/debug-id-stamping'; interface SentryVitePlugin { name: string; - enforce: 'pre'; + enforce: 'pre' | 'post'; + generateBundle?: { + order: 'pre' | 'post'; + handler: (_outputOptions: unknown, bundle: OutputBundle) => void; + }; } function getViteMajorVersion(): string | undefined { @@ -22,12 +27,30 @@ function getViteMajorVersion(): string | undefined { } export const sentryVitePlugin = (options?: SentryRollupPluginOptions): SentryVitePlugin[] => { - return [ + const plugins: SentryVitePlugin[] = [ { enforce: 'pre', ..._rollupPluginInternal(options, 'vite', getViteMajorVersion()), }, ]; + + if (!options?.disable && options?.sourcemaps?.disable === 'disable-upload') { + plugins.push({ + name: 'sentry-vite-debug-id-sourcemaps', + enforce: 'post', + generateBundle: { + order: 'post', + handler(_outputOptions, bundle) { + // Vite's module preload rewrite in `generateBundle` regenerates the source map of every + // chunk with a dynamic import, which drops the debug ID the main plugin stamped in its + // `pre` hook. Restore it without touching the chunk code, so file hashes stay valid. + stampDebugIds(bundle, false); + }, + }, + }); + } + + return plugins; }; export type { Options as SentryVitePluginOptions } from '../core'; diff --git a/packages/bundler-plugins/test/rollup/debug-id-injection.test.ts b/packages/bundler-plugins/test/rollup/debug-id-injection.test.ts new file mode 100644 index 000000000000..be5b87f2da9f --- /dev/null +++ b/packages/bundler-plugins/test/rollup/debug-id-injection.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { + finalizeRolldownDebugIds, + getDebugIdForChunk, + hasExistingDebugID, + ROLLDOWN_DEBUG_ID_PLACEHOLDER, + type GeneratedBundle, +} from '../../src/rollup/debug-id-injection'; + +const UUID_PATTERN = /[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}/g; + +function provisionalCode(userCode = 'console.log("test");'): string { + return [ + `globalThis._sentryDebugIds[stack]="${ROLLDOWN_DEBUG_ID_PLACEHOLDER}";`, + `globalThis._sentryDebugIdIdentifier="sentry-dbid-${ROLLDOWN_DEBUG_ID_PLACEHOLDER}";`, + userCode, + ].join(''); +} + +function finalize(code: string, fileName = 'bundle.js'): string { + const bundle: GeneratedBundle = { + [fileName]: { type: 'chunk', fileName, code }, + }; + + finalizeRolldownDebugIds(bundle); + const output = bundle[fileName]; + expect(output?.type).toBe('chunk'); + + return output?.type === 'chunk' ? output.code : ''; +} + +function extractDebugId(code: string): string { + const debugIds = code.match(UUID_PATTERN); + expect(debugIds).toHaveLength(2); + expect(new Set(debugIds)).toHaveLength(1); + + return debugIds?.[0] ?? ''; +} + +describe('debug ID injection', () => { + it('uses a placeholder for Rolldown and a deterministic UUID for Rollup', () => { + expect(getDebugIdForChunk('code', true)).toBe(ROLLDOWN_DEBUG_ID_PLACEHOLDER); + expect(getDebugIdForChunk('code', false)).toMatch(UUID_PATTERN); + expect(getDebugIdForChunk('code', false)).toBe(getDebugIdForChunk('code', false)); + }); + + it('detects existing inline and comment debug IDs at chunk boundaries', () => { + expect(hasExistingDebugID('globalThis._sentryDebugIdIdentifier="sentry-dbid-existing";')).toBe(true); + expect(hasExistingDebugID('console.log("test");\n//# debugId=existing')).toBe(true); + expect(hasExistingDebugID('console.log("test");')).toBe(false); + }); + + it('generates stable IDs from finalized code and filenames', () => { + const firstBuild = finalize(provisionalCode()); + const secondBuild = finalize(provisionalCode()); + + expect(extractDebugId(firstBuild)).toBe(extractDebugId(secondBuild)); + }); + + it('changes the ID when the code changes and keeps it across file names', () => { + const baseline = extractDebugId(finalize(provisionalCode('first'))); + + expect(extractDebugId(finalize(provisionalCode('second')))).not.toBe(baseline); + expect(extractDebugId(finalize(provisionalCode('first'), 'other.js'))).toBe(baseline); + }); + + it('does not replace placeholder-shaped user strings', () => { + const userCode = `console.log("${ROLLDOWN_DEBUG_ID_PLACEHOLDER}");`; + + expect(finalize(provisionalCode(userCode))).toContain(userCode); + }); + + it('does not replace marker-shaped strings before the injected identifier', () => { + const userCode = `// sentry-dbid-${ROLLDOWN_DEBUG_ID_PLACEHOLDER}\n`; + const finalizedCode = finalize(`${userCode}${provisionalCode()}`); + + expect(finalizedCode).toContain(userCode); + expect(extractDebugId(finalizedCode)).not.toBe(''); + }); + + it('fails when the injected placeholder is incomplete', () => { + const code = `globalThis._sentryDebugIdIdentifier="sentry-dbid-${ROLLDOWN_DEBUG_ID_PLACEHOLDER}";`; + + expect(() => finalize(code)).toThrow('Failed to locate the Sentry debug ID placeholder for chunk `bundle.js`.'); + }); + + it('ignores non-chunk assets and chunks without provisional IDs', () => { + const bundle: GeneratedBundle = { + 'asset.js': { type: 'asset', fileName: 'asset.js' }, + 'chunk.js': { type: 'chunk', fileName: 'chunk.js', code: 'console.log("test");' }, + }; + + finalizeRolldownDebugIds(bundle); + + expect(bundle).toEqual({ + 'asset.js': { type: 'asset', fileName: 'asset.js' }, + 'chunk.js': { type: 'chunk', fileName: 'chunk.js', code: 'console.log("test");' }, + }); + }); +}); diff --git a/packages/bundler-plugins/test/rollup/disable-upload.test.ts b/packages/bundler-plugins/test/rollup/disable-upload.test.ts index 23b9db3cf5a4..be868126501f 100644 --- a/packages/bundler-plugins/test/rollup/disable-upload.test.ts +++ b/packages/bundler-plugins/test/rollup/disable-upload.test.ts @@ -77,10 +77,22 @@ describe('sourcemaps.disable: "disable-upload"', () => { expect(chunk).toContain(`//# debugId=${debugId}`); }); - it('does not register the generateBundle hook when uploading is enabled', () => { + it('does not stamp the emitted files when uploading is enabled', () => { const [plugin] = sentryRollupPlugin({ telemetry: false }); - - expect(plugin).not.toHaveProperty('generateBundle'); + const code = 'globalThis._sentryDebugIdIdentifier="sentry-dbid-e3ee452d-c255-448c-aa62-da0fd4c49e46";'; + const bundle = { + 'entry.js': { type: 'chunk', fileName: 'entry.js', code }, + 'entry.js.map': { + type: 'asset', + fileName: 'entry.js.map', + source: JSON.stringify({ version: 3, sources: [], names: [], mappings: '' }), + }, + }; + + plugin.generateBundle.handler.call({ meta: {} }, {}, bundle); + + expect(bundle['entry.js'].code).toBe(code); + expect(JSON.parse(bundle['entry.js.map'].source)).not.toHaveProperty('debug_id'); }); it('stamps the chunk when the source map is inlined into the chunk', async () => { diff --git a/packages/bundler-plugins/test/rollup/rolldown-determinism.test.ts b/packages/bundler-plugins/test/rollup/rolldown-determinism.test.ts new file mode 100644 index 000000000000..cefed166d764 --- /dev/null +++ b/packages/bundler-plugins/test/rollup/rolldown-determinism.test.ts @@ -0,0 +1,139 @@ +import { rolldown } from 'rolldown'; +import { describe, expect, it } from 'vitest'; +import { sentryRollupPlugin } from '../../src/rollup'; + +const virtualModules: Record = { + 'virtual:app': `export async function loadShared() { return import('virtual:shared'); }`, + 'virtual:shared': `export const shared = 'shared';`, + 'virtual:unrelated': `console.log('unrelated');`, +}; + +type SourceMapMode = boolean | 'inline' | 'hidden'; +type OutputFormat = 'esm' | 'cjs'; + +const outputCases: [OutputFormat, SourceMapMode][] = [ + ['esm', true], + ['esm', false], + ['esm', 'inline'], + ['esm', 'hidden'], + ['cjs', true], + ['cjs', false], + ['cjs', 'inline'], + ['cjs', 'hidden'], +]; + +async function createBuild(includeUnrelatedEntry: boolean, observeBundle?: (codes: string[]) => void) { + const input: Record = includeUnrelatedEntry + ? { unrelated: 'virtual:unrelated', app: 'virtual:app' } + : { app: 'virtual:app' }; + const options = { + input, + plugins: [ + { + name: 'integrity-observer', + generateBundle(_outputOptions: unknown, bundle: Record) { + observeBundle?.( + Object.values(bundle).flatMap(output => (output.type === 'chunk' ? [output.code ?? ''] : [])), + ); + }, + }, + { + name: 'virtual-modules', + resolveId(id: string) { + return id in virtualModules ? id : null; + }, + load(id: string) { + return virtualModules[id] ?? null; + }, + }, + ...sentryRollupPlugin({ + release: { inject: false }, + sourcemaps: { disable: 'disable-upload' }, + telemetry: false, + }), + ], + }; + + return rolldown(options); +} + +async function build( + includeUnrelatedEntry: boolean, + sourcemap: SourceMapMode = true, + format: OutputFormat = 'esm', + observeBundle?: (codes: string[]) => void, +) { + const bundle = await createBuild(includeUnrelatedEntry, observeBundle); + + try { + const { output } = await bundle.generate({ + format, + sourcemap, + entryFileNames: '[name]-[hash].js', + chunkFileNames: '[name]-[hash].js', + }); + + return output.filter(outputFile => outputFile.type === 'chunk'); + } finally { + await bundle.close(); + } +} + +function expectFinalizedDebugId(code: string): void { + expect(code).not.toContain('SENTRY_DEBUG_ID_PLACEHOLDER_00000000'); + const debugIds = code.match(/[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}/g); + expect(debugIds?.length).toBeGreaterThanOrEqual(2); + expect(new Set(debugIds)).toHaveLength(1); +} + +describe('Rolldown debug ID determinism', () => { + it('finalizes and stamps chunks before later bundle observers run', async () => { + let observedCodes: string[] = []; + + const chunks = await build(false, true, 'esm', codes => { + observedCodes = codes; + }); + + expect(observedCodes).toHaveLength(chunks.length); + expect(observedCodes).toEqual(chunks.map(chunk => chunk.code)); + for (const code of observedCodes) { + expect(code).toContain('//# debugId='); + expectFinalizedDebugId(code); + } + }); + + it.each(outputCases)( + 'produces identical %s chunks in repeated builds with sourcemap=%s', + async (format, sourcemap) => { + const firstBuild = await build(false, sourcemap, format); + const secondBuild = await build(false, sourcemap, format); + + const comparableOutput = (chunks: typeof firstBuild) => + chunks.map(chunk => ({ + fileName: chunk.fileName, + code: chunk.code, + map: chunk.map?.toString(), + })); + expect(comparableOutput(secondBuild)).toEqual(comparableOutput(firstBuild)); + for (const chunk of firstBuild) { + expectFinalizedDebugId(chunk.code); + } + }, + ); + + it('keeps existing chunks stable when an unrelated entry changes placeholder allocation', async () => { + const firstBuild = await build(false); + const secondBuild = await build(true); + + for (const facadeModuleId of ['virtual:app', 'virtual:shared']) { + const firstChunk = firstBuild.find(chunk => chunk.facadeModuleId === facadeModuleId); + const secondChunk = secondBuild.find(chunk => chunk.facadeModuleId === facadeModuleId); + + expect(firstChunk).toBeDefined(); + expect(secondChunk).toBeDefined(); + expect(secondChunk?.fileName).toBe(firstChunk?.fileName); + expect(secondChunk?.code).toBe(firstChunk?.code); + expectFinalizedDebugId(firstChunk?.code ?? ''); + } + }); +}); diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 4440768bd111..e7b9cfc28d13 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -53,7 +53,7 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "@sentry/opentelemetry": "11.2.0", "@sentry/server-utils": "11.2.0", diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index 9adc9e5115f0..af2b4481553d 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -10,6 +10,7 @@ import { instrumentDurableObjectHandlers } from './instrumentations/instrumentDu import { instrumentEnv } from './instrumentations/worker/instrumentEnv'; import { getFinalOptions } from './options'; import { instrumentContext } from './utils/instrumentContext'; +import { withConstructionIsolationScope } from './utils/invocationScope'; import { hasRpcMeta } from './utils/rpcMeta'; import { instrumentCloudflareAgent } from './instrumentations/agents'; import type { DefaultEnv, ResolveEnv, StrictCloudflareOptions } from './types'; @@ -67,7 +68,9 @@ export function constructInstrumentedDurableObject // Pass `newTarget` so that subclasses of the instrumented class (e.g. the wrapper classes // created by wrangler's local dev tooling or `@cloudflare/vitest-pool-workers`) keep their // own prototype — otherwise subclass methods disappear and `instanceof` checks break. - const obj = Reflect.construct(target, [context, instrumentedEnv], newTarget) as T; + const obj = withConstructionIsolationScope( + () => Reflect.construct(target, [context, instrumentedEnv], newTarget) as T, + ); const frameworkManagedMethods = resolveFrameworkManagedMethods( prototype, @@ -298,9 +301,10 @@ function createRpcPrototypeWrapper(methodName: string, originalMethod: Unchecked const wrapper = function (this: unknown, ...args: unknown[]): unknown { const traced = hasRpcMeta(args); - // workerd dispatches an incoming RPC call outside any async context, so a call made while an - // invocation is already in flight comes from the instance itself (`this.helper()` inside - // `fetch`, `alarm` or another RPC method). Check that before touching per-instance state. + // workerd dispatches an incoming RPC call outside any async context, so a call made on any other + // isolation scope comes from the instance itself (`this.helper()` inside `fetch`, `alarm`, + // another RPC method or async work the constructor started). Check that before touching + // per-instance state. if (!traced && getIsolationScope() !== getDefaultIsolationScope()) { return Reflect.apply(originalMethod, this, args); } diff --git a/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts b/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts index e7ae5e5d173f..706960562f88 100644 --- a/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts +++ b/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts @@ -1,10 +1,24 @@ import type { SqlStorage } from '@cloudflare/workers-types'; import { SENTRY_OP } from '@sentry/conventions/attributes'; import { DB_QUERY } from '@sentry/conventions/op'; -import { getClient, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; +import { + getActiveSpan, + getClient, + hasSpansEnabled, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + spanIsSampled, + startSpan, +} from '@sentry/core'; import { getSqlQuerySummary, sanitizeSqlQuery } from '@sentry/server-utils'; import type { CloudflareClientOptions } from '../client'; -import { targetsCloudflareInternalTable } from '../utils/internalSqlQuery'; +import { mayTargetCloudflareInternalTable, targetsCloudflareInternalTable } from '../utils/internalSqlQuery'; + +const SPAN_ATTRIBUTES = { + [SENTRY_OP]: DB_QUERY, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object.sql', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'exec', +}; /** * Instruments the Durable Object SqlStorage `exec` method with Sentry spans. @@ -22,8 +36,19 @@ export function instrumentSqlStorage(sql: SqlStorage): SqlStorage { } return function (this: unknown, ...args: unknown[]) { + const callOriginal = (): ReturnType => + (original as (...a: unknown[]) => ReturnType).apply(target, args); + const [query, ...bindings] = args as [string, ...unknown[]]; + if (childSpanWillNotBeRecorded() && !mayTargetCloudflareInternalTable(query)) { + // This span is never sent, so skip the costly sanitize and summary + // steps. We still start the span so it records its dropped span + // outcome. A query that may target a `cf_` table takes the full path, + // because an internal query must start no span. + return startSpan({ name: 'exec', attributes: SPAN_ATTRIBUTES }, callOriginal); + } + const sanitizedQuery = sanitizeSqlQuery(query); const querySummary = getSqlQuerySummary(sanitizedQuery); @@ -32,25 +57,35 @@ export function instrumentSqlStorage(sql: SqlStorage): SqlStorage { ?.durableObjectSqlSpanAllowlist; if (targetsCloudflareInternalTable(querySummary, allowlist, sanitizedQuery)) { - return (original as (...a: unknown[]) => ReturnType).apply(target, args); + return callOriginal(); } return startSpan( { name: querySummary || sanitizedQuery, attributes: { - [SENTRY_OP]: DB_QUERY, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object.sql', - 'db.system.name': 'cloudflare-durable-object-sql', - 'db.operation.name': 'exec', + ...SPAN_ATTRIBUTES, 'db.query.text': sanitizedQuery, 'db.query.summary': querySummary, 'cloudflare.durable_object.query.bindings': bindings.length, }, }, - () => (original as (...a: unknown[]) => ReturnType).apply(target, args), + callOriginal, ); }; }, }); } + +/** + * Returns `true` when `startSpan` creates a non-recording span: spans are disabled, or the active span + * is not sampled. Must agree with `createChildOrRootSpan` and `_startChildSpan` in `@sentry/core`. + */ +function childSpanWillNotBeRecorded(): boolean { + if (!hasSpansEnabled()) { + return true; + } + + const activeSpan = getActiveSpan(); + return !!activeSpan && !spanIsSampled(activeSpan); +} diff --git a/packages/cloudflare/src/utils/internalSqlQuery.ts b/packages/cloudflare/src/utils/internalSqlQuery.ts index 7c67415d4da9..70d45a5af9d8 100644 --- a/packages/cloudflare/src/utils/internalSqlQuery.ts +++ b/packages/cloudflare/src/utils/internalSqlQuery.ts @@ -37,6 +37,16 @@ export function targetsCloudflareInternalTable( return tables.some(table => isCloudflareInternalTable(table, allowlist)); } +/** + * Returns `false` when the raw `query` has no word that starts with `cf_`, so it cannot target a + * Cloudflare internal table. Needs no sanitizing, which makes it cheap enough to run on every query. + */ +export function mayTargetCloudflareInternalTable(query: string): boolean { + return CF_PREFIX_RE.test(query); +} + +const CF_PREFIX_RE = /\bcf_/i; + // `CREATE [UNIQUE] INDEX [IF NOT EXISTS] ON ` — the IF EXISTS shape mirrors DDL_RE // in @sentry/core. const CREATE_INDEX_TABLE_RE = diff --git a/packages/cloudflare/src/utils/invocationScope.ts b/packages/cloudflare/src/utils/invocationScope.ts index aaaf98fdaa2a..cd0d94aed6db 100644 --- a/packages/cloudflare/src/utils/invocationScope.ts +++ b/packages/cloudflare/src/utils/invocationScope.ts @@ -2,6 +2,27 @@ import { getDefaultIsolationScope, getIsolationScope, type Scope, withIsolationS import type { ExecutionContextCompat } from '../executionContext'; import { setInvocationState } from './invocationContext'; +const constructionScopes = new WeakSet(); + +/** + * Runs a Durable Object constructor on a forked isolation scope. + * + * Async work the constructor starts, such as a `blockConcurrencyWhile` callback, runs after the RPC + * wrappers are installed and inherits this scope, so the calls it makes to the instance's own methods + * are not treated as incoming RPC calls. The scope is not an invocation: it has no invocation state, + * and an instrumented handler that this work calls still forks a scope of its own. + */ +export function withConstructionIsolationScope(callback: () => T): T { + if (getIsolationScope() !== getDefaultIsolationScope()) { + return callback(); + } + + return withIsolationScope(scope => { + constructionScopes.add(scope); + return callback(); + }); +} + /** * Runs `callback` on the isolation scope for the current invocation. * @@ -19,14 +40,15 @@ import { setInvocationState } from './invocationContext'; * * The AsyncLocalStorage strategy hands the default isolation scope back whenever no invocation is in * flight, and a forked one while inside `withIsolationScope`. Reference-comparing against the default - * is therefore enough to tell the two cases apart. The stack fallback does not fork, so it reports the - * default scope even inside an invocation; there the fork degrades to a no-op, which the stack strategy - * tolerates. This matches the approach used by `patchEventHandler` in Nuxt. + * and the construction scopes (see {@link withConstructionIsolationScope}) is therefore enough to tell + * the two cases apart. The stack fallback does not fork, so it reports the default scope even inside an + * invocation; there the fork degrades to a no-op, which the stack strategy tolerates. This matches the + * approach used by `patchEventHandler` in Nuxt. */ export function withInvocationIsolationScope(callback: (scope: Scope) => T, context?: ExecutionContextCompat): T { const isolationScope = getIsolationScope(); - const isEntryPoint = isolationScope === getDefaultIsolationScope(); + const isEntryPoint = isolationScope === getDefaultIsolationScope() || constructionScopes.has(isolationScope); const newIsolationScope = isEntryPoint ? isolationScope.clone() : isolationScope; if (isEntryPoint) { diff --git a/packages/cloudflare/src/utils/traceLinks.ts b/packages/cloudflare/src/utils/traceLinks.ts index c016243d02c4..805d8058483b 100644 --- a/packages/cloudflare/src/utils/traceLinks.ts +++ b/packages/cloudflare/src/utils/traceLinks.ts @@ -1,7 +1,8 @@ +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import type { DurableObjectStorage } from '@cloudflare/workers-types'; import { TraceFlags } from '@opentelemetry/api'; import type { SpanLink } from '@sentry/core'; -import { debug, getActiveSpan, SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE, spanIsSampled } from '@sentry/core'; +import { debug, getActiveSpan, spanIsSampled } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; /** Storage key prefix for the span context that links consecutive method invocations */ @@ -74,7 +75,7 @@ export function buildSpanLinks(storedContext: StoredSpanContext): SpanLink[] { traceFlags: storedContext.sampled ? TraceFlags.SAMPLED : TraceFlags.NONE, }, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }, ]; diff --git a/packages/cloudflare/test/durableobject.test.ts b/packages/cloudflare/test/durableobject.test.ts index e6f4e1bcd551..6f7e2f47744a 100644 --- a/packages/cloudflare/test/durableobject.test.ts +++ b/packages/cloudflare/test/durableobject.test.ts @@ -216,6 +216,110 @@ describe('instrumentDurableObjectWithSentry', () => { expect(events[2]?.user).toBeUndefined(); }); + // Work the constructor starts shares one scope, so a handler it calls must not reuse that scope. + it('Built-in handlers called from work the constructor started each get their own isolation scope', async () => { + const events: Event[] = []; + const waits: Promise[] = []; + const mockContext = { + waitUntil: vi.fn((promise: Promise) => { + waits.push(promise); + }), + blockConcurrencyWhile: (callback: () => Promise) => Promise.resolve().then(callback), + } as any; + + const testClass = class { + initialized: Promise; + + constructor(ctx: { blockConcurrencyWhile(callback: () => Promise): Promise }) { + this.initialized = ctx.blockConcurrencyWhile(async () => { + await this.webSocketMessage({}, 'seed'); + await this.webSocketMessage({}, 'probe'); + }); + } + + webSocketMessage(_ws: unknown, message: string) { + if (message === 'seed') { + SentryCore.setTag('seeded_tag', 'from-seeding-message'); + SentryCore.setUser({ id: 'user-from-seeding-message' }); + } + + SentryCore.captureMessage(message); + } + }; + const obj = Reflect.construct( + instrumentDurableObjectWithSentry( + () => ({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + beforeSend(event: Event) { + events.push(event); + return null; + }, + }), + testClass as any, + ), + [mockContext, {} as any], + ); + + await obj.initialized; + await Promise.all(waits); + + expect(events.map(event => event.message)).toEqual(['seed', 'probe']); + // Guards the assertions below against passing vacuously. + expect(events[0]?.tags?.seeded_tag).toBe('from-seeding-message'); + expect(events[0]?.user).toEqual({ id: 'user-from-seeding-message' }); + + expect(events[1]?.tags?.seeded_tag).toBeUndefined(); + expect(events[1]?.user).toBeUndefined(); + }); + + // The only scope that all later invocations share is the default isolation scope of the isolate, so + // data set in the constructor stays in the constructor's own scope. + it('does not apply scope data set in the constructor to later invocations', async () => { + const events: Event[] = []; + const waits: Promise[] = []; + const mockContext = { + waitUntil: vi.fn((promise: Promise) => { + waits.push(promise); + }), + } as any; + + const testClass = class { + constructor() { + SentryCore.setTag('constructor_tag', 'from-constructor'); + SentryCore.setUser({ id: 'user-from-constructor' }); + } + + alarm() { + SentryCore.captureMessage('alarm'); + } + + rpcMethod() { + SentryCore.captureMessage('rpc'); + } + }; + const obj = Reflect.construct( + instrumentDurableObjectWithSentry( + () => ({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + beforeSend(event: Event) { + events.push(event); + return null; + }, + }), + testClass as any, + ), + [mockContext, {} as any], + ); + + await obj.alarm(); + obj.rpcMethod(); + await Promise.all(waits); + + expect(events.map(event => event.message)).toEqual(['alarm', 'rpc']); + expect(events.map(event => event.tags?.constructor_tag)).toEqual([undefined, undefined]); + expect(events.map(event => event.user)).toEqual([undefined, undefined]); + }); + it('Built-in durable object methods are always instrumented', () => { const testClass = class { fetch() {} @@ -654,6 +758,9 @@ describe('instrumentDurableObjectWithSentry', () => { const waitUntil = vi.fn((promise: Promise) => { waits.push(promise); }); + // Like workerd, runs the callback after the constructor returns, in the async context of the caller. + const blockConcurrencyWhile = (callback: () => Promise): Promise => + Promise.resolve().then(callback); const instrumented = instrumentDurableObjectWithSentry( () => ({ @@ -675,7 +782,7 @@ describe('instrumentDurableObjectWithSentry', () => { }), testClass as any, ); - const obj = Reflect.construct(instrumented, [{ waitUntil }, {}]) as InstanceType; + const obj = Reflect.construct(instrumented, [{ waitUntil, blockConcurrencyWhile }, {}]) as InstanceType; const settle = async (): Promise => { while (waits.length) { await Promise.all(waits.splice(0)); @@ -769,6 +876,42 @@ describe('instrumentDurableObjectWithSentry', () => { expect(waitUntil).toHaveBeenCalledOnce(); }); + it('does not instrument calls from a blockConcurrencyWhile callback the constructor started', async () => { + const { obj, events, waitUntil, settle } = setup( + class { + failure?: Error; + initialized: Promise; + + constructor(ctx: { blockConcurrencyWhile(callback: () => Promise): Promise }) { + this.initialized = ctx.blockConcurrencyWhile(async () => { + try { + this.init(); + } catch (error) { + this.failure = error as Error; + } + }); + } + + init(): void { + throw new Error('Init failed'); + } + + async ping(): Promise { + const status = this.failure ? 'degraded' : 'ok'; + SentryCore.captureMessage(`ping: ${status}`); + return status; + } + }, + ); + + await obj.initialized; + await expect(obj.ping()).resolves.toBe('degraded'); + await settle(); + + expect(events.map(event => event.message)).toEqual(['ping: degraded']); + expect(waitUntil).toHaveBeenCalledOnce(); + }); + it('continues the caller trace when the call carries trace metadata', async () => { const { obj, events, transactions, settle } = setup( class { diff --git a/packages/cloudflare/test/instrumentDurableObjectStorage.test.ts b/packages/cloudflare/test/instrumentDurableObjectStorage.test.ts index 27d393ddb6ee..af5161e5bcde 100644 --- a/packages/cloudflare/test/instrumentDurableObjectStorage.test.ts +++ b/packages/cloudflare/test/instrumentDurableObjectStorage.test.ts @@ -3,6 +3,7 @@ import * as sentryCore from '@sentry/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { instrumentDurableObjectStorage } from '../src/instrumentations/instrumentDurableObjectStorage'; import * as traceLinks from '../src/utils/traceLinks'; +import { initTestClient, resetSdk } from './testUtils'; vi.mock('../src/utils/traceLinks', async importOriginal => { const actual = await importOriginal(); @@ -15,6 +16,7 @@ vi.mock('../src/utils/traceLinks', async importOriginal => { describe('instrumentDurableObjectStorage', () => { afterEach(() => { vi.restoreAllMocks(); + resetSdk(); }); describe('get', () => { @@ -301,6 +303,7 @@ describe('instrumentDurableObjectStorage', () => { }); it('instruments sql exec', () => { + initTestClient({ tracesSampleRate: 1 }); const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); const mockStorage = createMockStorage(); const instrumented = instrumentDurableObjectStorage(mockStorage); diff --git a/packages/cloudflare/test/instrumentSqlStorage.test.ts b/packages/cloudflare/test/instrumentSqlStorage.test.ts index 3436d0eb9032..8d76316460f3 100644 --- a/packages/cloudflare/test/instrumentSqlStorage.test.ts +++ b/packages/cloudflare/test/instrumentSqlStorage.test.ts @@ -1,11 +1,18 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import * as sentryCore from '@sentry/core'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as serverUtils from '@sentry/server-utils'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { instrumentSqlStorage } from '../src/instrumentations/instrumentSqlStorage'; +import { initTestClient, resetSdk } from './testUtils'; describe('instrumentSqlStorage', () => { + beforeEach(() => { + initTestClient({ tracesSampleRate: 1 }); + }); + afterEach(() => { vi.restoreAllMocks(); + resetSdk(); }); it('instruments exec with summary as span name and sanitized query as db.query.text', () => { @@ -259,6 +266,86 @@ describe('instrumentSqlStorage', () => { }); }); }); + + it('starts a span without sanitizing the query when tracing is not configured', () => { + initTestClient(); + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const sanitizeSpy = vi.spyOn(serverUtils, 'sanitizeSqlQuery'); + const mockSql = createMockSqlStorage(); + const instrumented = instrumentSqlStorage(mockSql); + + instrumented.exec('SELECT * FROM users WHERE id = ?', 42); + + expect(startSpanSpy).toHaveBeenCalledWith( + { + name: 'exec', + attributes: { + 'sentry.op': 'db.query', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object.sql', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'exec', + }, + }, + expect.any(Function), + ); + expect(sanitizeSpy).not.toHaveBeenCalled(); + expect(mockSql.exec).toHaveBeenCalledWith('SELECT * FROM users WHERE id = ?', 42); + }); + + describe('inside a parent span', () => { + it('with tracesSampleRate 1, sanitizes the query and names the span after it', () => { + const sanitizeSpy = vi.spyOn(serverUtils, 'sanitizeSqlQuery'); + const instrumented = instrumentSqlStorage(createMockSqlStorage()); + + sentryCore.startSpan({ name: 'parent' }, () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + + instrumented.exec('SELECT * FROM users WHERE id = ?', 42); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ name: 'SELECT users' }), + expect.any(Function), + ); + }); + + expect(sanitizeSpy).toHaveBeenCalledTimes(1); + }); + + it('with tracesSampleRate 0, records the dropped span without sanitizing the query', () => { + const client = initTestClient({ tracesSampleRate: 0 }); + const sanitizeSpy = vi.spyOn(serverUtils, 'sanitizeSqlQuery'); + const mockSql = createMockSqlStorage(); + const instrumented = instrumentSqlStorage(mockSql); + + sentryCore.startSpan({ name: 'parent' }, () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + + instrumented.exec('SELECT * FROM users WHERE id = ?', 42); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(recordDroppedEventSpy).toHaveBeenCalledWith('sample_rate', 'span'); + }); + + expect(sanitizeSpy).not.toHaveBeenCalled(); + expect(mockSql.exec).toHaveBeenCalledWith('SELECT * FROM users WHERE id = ?', 42); + }); + + it('with tracesSampleRate 0, records no dropped span for Cloudflare-internal queries', () => { + const client = initTestClient({ tracesSampleRate: 0 }); + const instrumented = instrumentSqlStorage(createMockSqlStorage()); + + sentryCore.startSpan({ name: 'parent' }, () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + + instrumented.exec('SELECT * FROM cf_agents_state WHERE id = ?', 'foo'); + + expect(startSpanSpy).not.toHaveBeenCalled(); + expect(recordDroppedEventSpy).not.toHaveBeenCalled(); + }); + }); + }); }); /** diff --git a/packages/cloudflare/test/testUtils.ts b/packages/cloudflare/test/testUtils.ts index 79bdfa403365..5956935f8eba 100644 --- a/packages/cloudflare/test/testUtils.ts +++ b/packages/cloudflare/test/testUtils.ts @@ -1,5 +1,7 @@ import { context, propagation, trace } from '@opentelemetry/api'; -import { getMainCarrier } from '@sentry/core'; +import { getMainCarrier, setCurrentClient } from '@sentry/core'; +import { vi } from 'vitest'; +import { CloudflareClient, type CloudflareClientOptions } from '../src/client'; import { _clearGlobalClientCache } from '../src/clientCache'; function resetGlobals(): void { @@ -18,3 +20,26 @@ export function resetSdk(): void { cleanupOtel(); _clearGlobalClientCache(); } + +/** + * Resets the SDK and sets a `CloudflareClient` with a DSN and a mock transport as the current client. + * `options` are passed to the client, for example `tracesSampleRate`. + */ +export function initTestClient(options: Partial = {}): CloudflareClient { + resetSdk(); + + const client = new CloudflareClient({ + dsn: 'https://123@sentry.io/42', + stackParser: () => [], + integrations: [], + transport: () => ({ + send: vi.fn().mockResolvedValue({}), + flush: vi.fn().mockResolvedValue(true), + }), + ...options, + }); + setCurrentClient(client); + client.init(); + + return client; +} diff --git a/packages/cloudflare/test/utils/internalSqlQuery.test.ts b/packages/cloudflare/test/utils/internalSqlQuery.test.ts index 72d01d58ad9e..0c9d6c641dde 100644 --- a/packages/cloudflare/test/utils/internalSqlQuery.test.ts +++ b/packages/cloudflare/test/utils/internalSqlQuery.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { targetsCloudflareInternalTable } from '../../src/utils/internalSqlQuery'; +import { mayTargetCloudflareInternalTable, targetsCloudflareInternalTable } from '../../src/utils/internalSqlQuery'; // Behavioural coverage of the filter lives in `instrumentSqlStorage.test.ts`, which drives real // queries through the instrumented `exec`. What remains here are the signature-level contracts that @@ -23,3 +23,23 @@ describe('targetsCloudflareInternalTable', () => { expect(targetsCloudflareInternalTable('CREATE INDEX idx_agents_state_id')).toBe(false); }); }); + +describe('mayTargetCloudflareInternalTable', () => { + it.each([ + ['a cf_ table', 'SELECT * FROM cf_agents_state WHERE id = ?'], + ['an uppercase CF_ table', 'select * from CF_AGENTS_STATE'], + ['a quoted cf_ table', 'SELECT * FROM "cf_agents_state"'], + ['a schema-qualified cf_ table', 'SELECT * FROM main.cf_agents_state'], + ['a cf_ table in a CREATE INDEX ON clause', 'CREATE INDEX idx_agents_state_id ON cf_agents_state (id)'], + ])('returns true for %s', (_label, query) => { + expect(mayTargetCloudflareInternalTable(query)).toBe(true); + }); + + it.each([ + ['a user table', 'SELECT * FROM users WHERE id = ?'], + ['a table with cf in the middle', 'SELECT * FROM my_cf_table'], + ['a table starting with cfg', 'SELECT * FROM cfg_settings'], + ])('returns false for %s', (_label, query) => { + expect(mayTargetCloudflareInternalTable(query)).toBe(false); + }); +}); diff --git a/packages/core/package.json b/packages/core/package.json index 3524a2a51c11..c67dc3007ccc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -94,6 +94,6 @@ "zod": "^3.24.1" }, "dependencies": { - "@sentry/conventions": "^0.24.0" + "@sentry/conventions": "^0.25.0" } } diff --git a/packages/core/src/integrations/conversationId.ts b/packages/core/src/integrations/conversationId.ts index 4cbcded45be5..31b4de5f092c 100644 --- a/packages/core/src/integrations/conversationId.ts +++ b/packages/core/src/integrations/conversationId.ts @@ -1,7 +1,7 @@ +import { GEN_AI_CONVERSATION_ID } from '@sentry/conventions/attributes'; import type { Client } from '../client'; import { getCurrentScope, getIsolationScope } from '../currentScopes'; import { defineIntegration } from '../integration'; -import { GEN_AI_CONVERSATION_ID_ATTRIBUTE } from '../semanticAttributes'; import type { IntegrationFn } from '../types/integration'; import type { Span } from '../types/span'; import { spanToStaticSpanJSON } from '../utils/spanUtils'; @@ -29,7 +29,7 @@ const _conversationIdIntegration = (() => { return; } - span.setAttribute(GEN_AI_CONVERSATION_ID_ATTRIBUTE, conversationId); + span.setAttribute(GEN_AI_CONVERSATION_ID, conversationId); } }); }, diff --git a/packages/core/src/integrations/http/server-subscription.ts b/packages/core/src/integrations/http/server-subscription.ts index bc09da8ba6d9..3ef8edbf0a49 100644 --- a/packages/core/src/integrations/http/server-subscription.ts +++ b/packages/core/src/integrations/http/server-subscription.ts @@ -69,6 +69,7 @@ import { } from '@sentry/conventions/attributes'; import { HTTP_SERVER } from '@sentry/conventions/op'; import { filterCollectedUrl, filterCollectedUrlQuery } from '../../utils/data-collection/filterCollectedUrl'; +import { getClientIPAddress } from '../../utils/clientIPAddress'; // Tree-shakable guard to remove all code related to tracing declare const __SENTRY_TRACING__: boolean; @@ -306,7 +307,6 @@ function buildServerSpanWrap( : `${method} ${httpTargetWithoutQueryFragment}`; const headers = request.headers; const userAgent = headers['user-agent']; - const ips = headers['x-forwarded-for']; const httpVersion = request.httpVersion; const host = headers.host as undefined | string; const hostname = host?.replace(/^(.*)(:[0-9]{1,5})/, '$1') || 'localhost'; @@ -315,8 +315,10 @@ function buildServerSpanWrap( const { localAddress, localPort, remoteAddress, remotePort } = socket ?? {}; const collectClientAddress = client.getDataCollectionOptions().userInfo; // `client.address` is the originating client, so a forwarding header wins over the socket, which - // behind a proxy holds the proxy's address. `network.peer.address` keeps the socket value. - const clientAddress = getForwardedClientAddress(ips) ?? remoteAddress; + // behind a proxy holds the proxy's address. The socket port is the proxy's too, so `client.port` + // stays unset then. `network.peer.*` keeps the socket values. + const forwardedAddress = getClientIPAddress(headers); + const clientAddress = forwardedAddress || remoteAddress; return startSpanManual( { @@ -333,7 +335,7 @@ function buildServerSpanWrap( [NETWORK_LOCAL_ADDRESS]: localAddress, [NETWORK_LOCAL_PORT]: localPort, [CLIENT_ADDRESS]: collectClientAddress ? clientAddress : undefined, - [CLIENT_PORT]: remotePort, + [CLIENT_PORT]: forwardedAddress ? undefined : remotePort, [NETWORK_PEER_ADDRESS]: collectClientAddress ? remoteAddress : undefined, [NETWORK_PEER_PORT]: remotePort, [SENTRY_HTTP_PREFETCH]: isKnownPrefetchRequest(request) || undefined, @@ -392,14 +394,6 @@ function buildServerSpanWrap( }; } -/** - * First entry of `X-Forwarded-For`: the client as seen by the outermost proxy. - * https://opentelemetry.io/docs/specs/semconv/registry/attributes/client/#client-address - */ -function getForwardedClientAddress(forwardedFor: string | string[] | undefined): string | undefined { - return typeof forwardedFor === 'string' ? forwardedFor.split(',')[0]?.trim() || undefined : undefined; -} - function shouldIgnoreSpansForIncomingRequest( request: HttpIncomingMessage, { diff --git a/packages/core/src/integrations/requestdata.ts b/packages/core/src/integrations/requestdata.ts index a8e0dc14e0a2..12d9d99d433c 100644 --- a/packages/core/src/integrations/requestdata.ts +++ b/packages/core/src/integrations/requestdata.ts @@ -1,7 +1,6 @@ import type { Client } from '../client'; import { getIsolationScope } from '../currentScopes'; import { defineIntegration } from '../integration'; -import { SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS } from '../semanticAttributes'; import type { SdkProcessingMetadata } from '../scope'; import type { CollectBehavior, ResolvedDataCollection } from '../types/datacollection'; import type { Event } from '../types/event'; @@ -16,9 +15,10 @@ import { filterQueryParams } from '../utils/data-collection/filterQueryParams'; import { filterUrlQuery } from '../utils/data-collection/filterUrlQuery'; import { filterCookiePairs, httpHeadersToSpanAttributes } from '../utils/request'; import { getUrlQuery } from '../utils/url'; -import { getClientIPAddress, ipHeaderNames } from '../vendor/getIpAddress'; +import { getClientIPAddress } from '../utils/clientIPAddress'; +import { ipHeaderNames } from '../vendor/getIpAddress'; import { safeSetSpanJSONAttributes } from '../tracing/spans/captureSpan'; -import { SENTRY_IS_LOCALHOST, URL_FULL, URL_QUERY } from '@sentry/conventions/attributes'; +import { SENTRY_IS_LOCALHOST, URL_FULL, URL_QUERY, USER_IP_ADDRESS } from '@sentry/conventions/attributes'; type RequestDataIncludeOptions = { cookies?: boolean; @@ -261,7 +261,7 @@ function addNormalizedRequestDataToSpan( if (include.ip) { const ip = (normalizedRequest.headers && getClientIPAddress(normalizedRequest.headers)) || ipAddress || undefined; if (ip) { - safeSetSpanJSONAttributes(span, { [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: ip }); + safeSetSpanJSONAttributes(span, { [USER_IP_ADDRESS]: ip }); } } } diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index 40d8e009a293..f77eb63b8f56 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -31,10 +31,14 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN = 'sentry.origin'; * Streamed (v2) span statuses are reduced to `ok`/`error`, so we preserve the * message as an attribute instead of dropping it. This mirrors the attribute * Sentry's OTLP ingestion uses for the same purpose. + * @deprecated Use `SENTRY_STATUS_MESSAGE` from `@sentry/conventions/attributes` instead. */ export const SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE = 'sentry.status.message'; -/** The reason why an idle span finished. */ +/** + * The reason why an idle span finished. + * @deprecated Use `SENTRY_IDLE_SPAN_FINISH_REASON` from `@sentry/conventions/attributes` instead. + */ export const SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON = 'sentry.idle_span_finish_reason'; /** The unit of a measurement, which may be stored as a TimedEvent. */ @@ -43,9 +47,15 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT = 'sentry.measurement_un /** The value of a measurement, which may be stored as a TimedEvent. */ export const SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE = 'sentry.measurement_value'; -/** The release version of the application */ +/** + * The release version of the application + * @deprecated Use `SENTRY_RELEASE` from `@sentry/conventions/attributes` instead. + */ export const SEMANTIC_ATTRIBUTE_SENTRY_RELEASE = 'sentry.release'; -/** The environment name (e.g., "production", "staging", "development") */ +/** + * The environment name (e.g., "production", "staging", "development") + * @deprecated Use `SENTRY_ENVIRONMENT` from `@sentry/conventions/attributes` instead. + */ export const SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT = 'sentry.environment'; /** * The segment name (e.g., "GET /users") @@ -67,16 +77,31 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME = 'sentry.sdk.name'; * @deprecated Use `SENTRY_SDK_VERSION` `@sentry/conventions/attributes` instead. */ export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION = 'sentry.sdk.version'; -/** The list of integrations enabled in the Sentry SDK (e.g., ["EventFilters", "BrowserTracing"]) */ +/** + * The list of integrations enabled in the Sentry SDK (e.g., ["EventFilters", "BrowserTracing"]) + * @deprecated Use `SENTRY_SDK_INTEGRATIONS` from `@sentry/conventions/attributes` instead. + */ export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS = 'sentry.sdk.integrations'; -/** The user ID */ +/** + * The user ID + * @deprecated Use `USER_ID` from `@sentry/conventions/attributes` instead. + */ export const SEMANTIC_ATTRIBUTE_USER_ID = 'user.id'; -/** The user email */ +/** + * The user email + * @deprecated Use `USER_EMAIL` from `@sentry/conventions/attributes` instead. + */ export const SEMANTIC_ATTRIBUTE_USER_EMAIL = 'user.email'; -/** The user IP address */ +/** + * The user IP address + * @deprecated Use `USER_IP_ADDRESS` from `@sentry/conventions/attributes` instead. + */ export const SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS = 'user.ip_address'; -/** The user username */ +/** + * The user username + * @deprecated Use `USER_NAME` from `@sentry/conventions/attributes` instead. + */ export const SEMANTIC_ATTRIBUTE_USER_USERNAME = 'user.name'; /** @@ -90,18 +115,33 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME = 'sentry.custom_span_na /** * The id of the profile that this span occurred in. + * @deprecated Use `SENTRY_PROFILE_ID` from `@sentry/conventions/attributes` instead. */ export const SEMANTIC_ATTRIBUTE_PROFILE_ID = 'sentry.profile_id'; +/** + * @deprecated Use `SENTRY_EXCLUSIVE_TIME` from `@sentry/conventions/attributes` instead. + */ export const SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME = 'sentry.exclusive_time'; +/** + * @deprecated Use `CACHE_HIT` from `@sentry/conventions/attributes` instead. + */ export const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; +/** + * @deprecated Use `CACHE_KEY` from `@sentry/conventions/attributes` instead. + */ export const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; +/** + * @deprecated Use `CACHE_ITEM_SIZE` from `@sentry/conventions/attributes` instead. + */ export const SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE = 'cache.item_size'; -/** TODO: Remove these once we update to latest semantic conventions */ +/** + * @deprecated Use `HTTP_REQUEST_METHOD` from `@sentry/conventions/attributes` instead. + */ export const SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD = 'http.request.method'; /** * @deprecated Use `URL_FULL` `@sentry/conventions/attributes` instead. @@ -116,6 +156,7 @@ export const SEMANTIC_ATTRIBUTE_URL_FULL = 'url.full'; * - `next_trace`: The span links to the frontend root span of the next trace. (Not set by the SDK) * * Other values may be set as appropriate. + * @deprecated Use `SENTRY_LINK_TYPE` from `@sentry/conventions/attributes` instead. * @see https://develop.sentry.dev/sdk/telemetry/traces/span-links/#link-types */ export const SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE = 'sentry.link.type'; @@ -132,5 +173,6 @@ export const SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE = 'sentry.link.type'; * The conversation ID for linking messages across API calls. * For OpenAI Assistants API: thread_id * For LangGraph: configurable.thread_id + * @deprecated Use `GEN_AI_CONVERSATION_ID` from `@sentry/conventions/attributes` instead. */ export const GEN_AI_CONVERSATION_ID_ATTRIBUTE = 'gen_ai.conversation.id'; diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index e48e0ea1de5f..a34657db8d6a 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -23,6 +23,7 @@ export { processHttpServerTransactionEvent, } from './integrations/http/server-transaction-event'; export { recordRequestSession } from './integrations/http/record-request-session'; +export { getClientIPAddress } from './utils/clientIPAddress'; export { addOutgoingRequestBreadcrumb } from './integrations/http/add-outgoing-request-breadcrumb'; export { getRequestUrl, diff --git a/packages/core/src/tracing/idleSpan.ts b/packages/core/src/tracing/idleSpan.ts index c837b9dcccc6..32e23ee357ba 100644 --- a/packages/core/src/tracing/idleSpan.ts +++ b/packages/core/src/tracing/idleSpan.ts @@ -1,6 +1,6 @@ +import { SENTRY_IDLE_SPAN_FINISH_REASON } from '@sentry/conventions/attributes'; import { getClient, getCurrentScope, getIsolationScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; -import { SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON } from '../semanticAttributes'; import type { Span } from '../types/span'; import type { StartSpanOptions } from '../types/startSpanOptions'; import { debug } from '../utils/debug-logger'; @@ -303,8 +303,8 @@ export function startIdleSpan(startSpanOptions: StartSpanOptions, options: Parti } const attributes = spanJSON.data; - if (!attributes[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]) { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, _finishReason); + if (!attributes[SENTRY_IDLE_SPAN_FINISH_REASON]) { + span.setAttribute(SENTRY_IDLE_SPAN_FINISH_REASON, _finishReason); } // Set span status to 'ok' if it hasn't been explicitly set to an error status diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 8cb9bc95cbf8..abd27b52edf1 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -1,10 +1,8 @@ /* eslint-disable max-lines */ import { getClient, getCurrentScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; -import { SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; +import { SENTRY_PROFILE_ID, SENTRY_EXCLUSIVE_TIME, SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; import { - SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, - SEMANTIC_ATTRIBUTE_PROFILE_ID, SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -280,8 +278,8 @@ export class SentrySpan implements Span { timestamp: this._endTime, trace_id: this._traceId, origin: this._attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] as SpanOrigin | undefined, - profile_id: this._attributes[SEMANTIC_ATTRIBUTE_PROFILE_ID] as string | undefined, - exclusive_time: this._attributes[SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME] as number | undefined, + profile_id: this._attributes[SENTRY_PROFILE_ID] as string | undefined, + exclusive_time: this._attributes[SENTRY_EXCLUSIVE_TIME] as number | undefined, measurements: timedEventsToMeasurements(this._events), links: convertSpanLinksForEnvelope(this._links), }; diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index 24f09d2aa9e2..60a68697ae58 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -1,15 +1,6 @@ import type { RawAttributes } from '../../attributes'; import type { Client } from '../../client'; import type { ScopeData } from '../../scope'; -import { - SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, - SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, - SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, - SEMANTIC_ATTRIBUTE_USER_EMAIL, - SEMANTIC_ATTRIBUTE_USER_ID, - SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, - SEMANTIC_ATTRIBUTE_USER_USERNAME, -} from '../../semanticAttributes'; import type { SerializedStreamedSpan, Span, SpanAttributeValue, SpanJSON, StreamedSpanJSON } from '../../types/span'; import { getCombinedScopeData } from '../../utils/scopeData'; import { @@ -29,6 +20,13 @@ import { SENTRY_SEGMENT_ID, SENTRY_SEGMENT_NAME, SENTRY_TRACE_LIFECYCLE, + USER_EMAIL, + USER_ID, + USER_IP_ADDRESS, + USER_NAME, + SENTRY_ENVIRONMENT, + SENTRY_RELEASE, + SENTRY_SDK_INTEGRATIONS, } from '@sentry/conventions/attributes'; export type SerializedStreamedSpanWithSegmentSpan = SerializedStreamedSpan & { @@ -115,7 +113,7 @@ function applySdkMetadataToSegmentSpan(segmentSpanJSON: StreamedSpanJSON, client if (!integrationNames.length) return; safeSetSpanJSONAttributes(segmentSpanJSON, { - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: integrationNames, + [SENTRY_SDK_INTEGRATIONS]: integrationNames, }); } @@ -136,12 +134,12 @@ function commonSpanAttributes( [SENTRY_SEGMENT_ID]: serializedSegmentSpan.span_id, [SENTRY_SDK_NAME]: sdk?.sdk?.name, [SENTRY_SDK_VERSION]: sdk?.sdk?.version, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: release, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: environment || DEFAULT_ENVIRONMENT, - [SEMANTIC_ATTRIBUTE_USER_ID]: scopeData.user?.id, - [SEMANTIC_ATTRIBUTE_USER_EMAIL]: scopeData.user?.email, - [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: scopeData.user?.ip_address, - [SEMANTIC_ATTRIBUTE_USER_USERNAME]: scopeData.user?.username, + [SENTRY_RELEASE]: release, + [SENTRY_ENVIRONMENT]: environment || DEFAULT_ENVIRONMENT, + [USER_ID]: scopeData.user?.id, + [USER_EMAIL]: scopeData.user?.email, + [USER_IP_ADDRESS]: scopeData.user?.ip_address, + [USER_NAME]: scopeData.user?.username, ...(includeScopeAttributes ? scopeData.attributes : undefined), }; } diff --git a/packages/core/src/types/link.ts b/packages/core/src/types/link.ts index 9a117258200b..574d7b6337cf 100644 --- a/packages/core/src/types/link.ts +++ b/packages/core/src/types/link.ts @@ -1,10 +1,11 @@ +import type { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import type { SpanAttributeValue, SpanContextData } from './span'; type SpanLinkAttributes = { /** * Setting the link type to 'previous_trace' helps the Sentry product linking to the previous trace */ - 'sentry.link.type'?: string | 'previous_trace'; + [SENTRY_LINK_TYPE]?: string | 'previous_trace'; } & Record; export interface SpanLink { diff --git a/packages/core/src/utils/clientIPAddress.ts b/packages/core/src/utils/clientIPAddress.ts new file mode 100644 index 000000000000..eb16facb032f --- /dev/null +++ b/packages/core/src/utils/clientIPAddress.ts @@ -0,0 +1,47 @@ +import { getClientIPAddress as getBareClientIPAddress, ipHeaderNames } from '../vendor/getIpAddress'; + +const IP_HEADER_NAMES = new Set(ipHeaderNames.map(name => name.toLowerCase())); + +/** + * Get the IP address of the client sending a request, from its forwarding headers. + * + * The vendored implementation accepts only bare addresses, so the header values are + * normalized first. Some proxies (for example Azure App Service) add the client port, + * and RFC 7239 allows a quoted `Forwarded` value and any case for `for=`. + */ +export function getClientIPAddress(headers: { [key: string]: string | string[] | undefined }): string | null { + const normalized: { [key: string]: string } = {}; + + for (const [key, value] of Object.entries(headers)) { + const name = key.toLowerCase(); + if (value === undefined || !IP_HEADER_NAMES.has(name)) { + continue; + } + + const joined = Array.isArray(value) ? value.join(',') : value; + normalized[key] = + name === 'forwarded' + ? normalizeForwardedHeader(joined) + : joined + .split(',') + .map(ip => stripPort(ip.trim())) + .join(','); + } + + return getBareClientIPAddress(normalized); +} + +// The vendored parser reads the first `for=` pair only, so only that pair is kept. +function normalizeForwardedHeader(value: string): string { + const forPair = value + .split(/[,;]/) + .map(part => part.trim()) + .find(part => part.slice(0, 4).toLowerCase() === 'for='); + + return forPair ? `for=${stripPort(forPair.slice(4).replace(/^"(.*)"$/, '$1'))}` : ''; +} + +// `203.0.113.7:4711` -> `203.0.113.7`, `[2001:db8::1]:4711` -> `2001:db8::1` +function stripPort(ip: string): string { + return ip.match(/^\[([^\]]+)\](?::\d+)?$/)?.[1] ?? ip.match(/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/)?.[1] ?? ip; +} diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 9fe769198f45..2bf38cdb5864 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -5,12 +5,11 @@ import { serializeAttributes } from '../attributes'; import { getMainCarrier } from '../carrier'; import { getCurrentScope } from '../currentScopes'; import type { Scope } from '../scope'; -import { SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; +import { SENTRY_SEGMENT_NAME_SOURCE, SENTRY_STATUS_MESSAGE } from '@sentry/conventions/attributes'; import { SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE, } from '../semanticAttributes'; import type { SentrySpan } from '../tracing/sentrySpan'; import { isStatusErrorMessageValid, SPAN_STATUS_OK, SPAN_STATUS_UNSET } from '../tracing/spanstatus'; @@ -346,7 +345,7 @@ export function getSimpleStatus(status: SpanStatus | undefined): 'ok' | 'error' } /** - * Returns the span's attributes with the SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE attribute added + * Returns the span's attributes with the SENTRY_STATUS_MESSAGE attribute added * if the span has an error status message worth preserving. * * An explicitly set attribute is never overwritten. @@ -357,7 +356,7 @@ export function addStatusMessageAttribute( ): RawAttributes> { const statusMessage = getSimpleStatus(status) === 'error' ? status?.message : undefined; return { - ...(statusMessage && { [SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]: statusMessage }), + ...(statusMessage && { [SENTRY_STATUS_MESSAGE]: statusMessage }), ...attributes, }; } diff --git a/packages/core/src/utils/transactionEvent.ts b/packages/core/src/utils/transactionEvent.ts index 0ab07b478786..97bf3580ce8c 100644 --- a/packages/core/src/utils/transactionEvent.ts +++ b/packages/core/src/utils/transactionEvent.ts @@ -1,4 +1,4 @@ -import { SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, SEMANTIC_ATTRIBUTE_PROFILE_ID } from '../semanticAttributes'; +import { SENTRY_PROFILE_ID, SENTRY_EXCLUSIVE_TIME } from '@sentry/conventions/attributes'; import type { TransactionEvent } from '../types/event'; import type { SpanJSON } from '../types/span'; @@ -19,8 +19,8 @@ export function convertTransactionEventToSpanJson(event: TransactionEvent): Span timestamp: event.timestamp, trace_id: trace_id ?? '', origin, - profile_id: data?.[SEMANTIC_ATTRIBUTE_PROFILE_ID] as string | undefined, - exclusive_time: data?.[SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME] as number | undefined, + profile_id: data?.[SENTRY_PROFILE_ID] as string | undefined, + exclusive_time: data?.[SENTRY_EXCLUSIVE_TIME] as number | undefined, measurements: event.measurements, is_segment: true, }; @@ -45,8 +45,8 @@ export function convertSpanJsonToTransactionEvent(span: SpanJSON): TransactionEv origin: span.origin, data: { ...span.data, - ...(span.profile_id && { [SEMANTIC_ATTRIBUTE_PROFILE_ID]: span.profile_id }), - ...(span.exclusive_time && { [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: span.exclusive_time }), + ...(span.profile_id && { [SENTRY_PROFILE_ID]: span.profile_id }), + ...(span.exclusive_time && { [SENTRY_EXCLUSIVE_TIME]: span.exclusive_time }), }, }, }, diff --git a/packages/core/src/utils/url.ts b/packages/core/src/utils/url.ts index 63ecad6dc4d8..d7f45141a342 100644 --- a/packages/core/src/utils/url.ts +++ b/packages/core/src/utils/url.ts @@ -1,4 +1,5 @@ import { + HTTP_REQUEST_METHOD, HTTP_ROUTE, SERVER_ADDRESS, URL_DOMAIN, @@ -11,7 +12,7 @@ import { URL_SCHEME, URL_TEMPLATE, } from '@sentry/conventions/attributes'; -import { SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes'; import type { Client } from '../client'; import type { SpanAttributes } from '../types/span'; import type { TransactionSource } from '../types/transaction'; @@ -221,7 +222,7 @@ export function getHttpSpanDetailsFromUrlObject( } if (request?.method) { - attributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] = request.method.toUpperCase(); + attributes[HTTP_REQUEST_METHOD] = request.method.toUpperCase(); } if (urlObject) { diff --git a/packages/core/test/lib/integrations/conversationId.test.ts b/packages/core/test/lib/integrations/conversationId.test.ts index 3cd97a2d4b42..fa3bc639be62 100644 --- a/packages/core/test/lib/integrations/conversationId.test.ts +++ b/packages/core/test/lib/integrations/conversationId.test.ts @@ -1,7 +1,7 @@ +import { GEN_AI_CONVERSATION_ID } from '@sentry/conventions/attributes'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { getCurrentScope, getIsolationScope, setCurrentClient, startSpan } from '../../../src'; import { conversationIdIntegration } from '../../../src/integrations/conversationId'; -import { GEN_AI_CONVERSATION_ID_ATTRIBUTE } from '../../../src/semanticAttributes'; import { spanToStaticSpanJSON } from '../../../src/utils/spanUtils'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; @@ -28,7 +28,7 @@ describe('ConversationId', () => { startSpan({ name: 'test-span', op: 'gen_ai.chat' }, span => { const spanJSON = spanToStaticSpanJSON(span); - expect(spanJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBe('conv_test_123'); + expect(spanJSON.data[GEN_AI_CONVERSATION_ID]).toBe('conv_test_123'); }); }); @@ -37,7 +37,7 @@ describe('ConversationId', () => { startSpan({ name: 'test-span', op: 'gen_ai.chat' }, span => { const spanJSON = spanToStaticSpanJSON(span); - expect(spanJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBe('conv_isolation_456'); + expect(spanJSON.data[GEN_AI_CONVERSATION_ID]).toBe('conv_isolation_456'); }); }); @@ -47,14 +47,14 @@ describe('ConversationId', () => { startSpan({ name: 'test-span', op: 'gen_ai.chat' }, span => { const spanJSON = spanToStaticSpanJSON(span); - expect(spanJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBe('conv_current_789'); + expect(spanJSON.data[GEN_AI_CONVERSATION_ID]).toBe('conv_current_789'); }); }); it('does not apply conversation ID when not set in scope', () => { startSpan({ name: 'test-span', op: 'gen_ai.chat' }, span => { const spanJSON = spanToStaticSpanJSON(span); - expect(spanJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBeUndefined(); + expect(spanJSON.data[GEN_AI_CONVERSATION_ID]).toBeUndefined(); }); }); @@ -64,7 +64,7 @@ describe('ConversationId', () => { startSpan({ name: 'test-span', op: 'gen_ai.chat' }, span => { const spanJSON = spanToStaticSpanJSON(span); - expect(spanJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBeUndefined(); + expect(spanJSON.data[GEN_AI_CONVERSATION_ID]).toBeUndefined(); }); }); @@ -74,7 +74,7 @@ describe('ConversationId', () => { startSpan({ name: 'parent-span', op: 'gen_ai.invoke_agent' }, () => { startSpan({ name: 'child-span', op: 'gen_ai.chat' }, childSpan => { const childJSON = spanToStaticSpanJSON(childSpan); - expect(childJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBe('conv_nested_abc'); + expect(childJSON.data[GEN_AI_CONVERSATION_ID]).toBe('conv_nested_abc'); }); }); }); @@ -87,12 +87,12 @@ describe('ConversationId', () => { name: 'test-span', op: 'gen_ai.chat', attributes: { - [GEN_AI_CONVERSATION_ID_ATTRIBUTE]: 'conv_explicit', + [GEN_AI_CONVERSATION_ID]: 'conv_explicit', }, }, span => { const spanJSON = spanToStaticSpanJSON(span); - expect(spanJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBe('conv_from_scope'); + expect(spanJSON.data[GEN_AI_CONVERSATION_ID]).toBe('conv_from_scope'); }, ); }); @@ -102,7 +102,7 @@ describe('ConversationId', () => { startSpan({ name: 'db-query', op: 'db.query' }, span => { const spanJSON = spanToStaticSpanJSON(span); - expect(spanJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBeUndefined(); + expect(spanJSON.data[GEN_AI_CONVERSATION_ID]).toBeUndefined(); }); }); }); diff --git a/packages/core/test/lib/integrations/http/server-subscription.test.ts b/packages/core/test/lib/integrations/http/server-subscription.test.ts index 3b749134bd9d..8f8addf7e78a 100644 --- a/packages/core/test/lib/integrations/http/server-subscription.test.ts +++ b/packages/core/test/lib/integrations/http/server-subscription.test.ts @@ -153,12 +153,32 @@ describe('getHttpServerSubscriptions', () => { await makeRequest('/users/42', 'GET', { 'X-Forwarded-For': '203.0.113.7, 198.51.100.1' }); const transaction = await waitForTransaction(); - expect(transaction.contexts?.trace?.data).toEqual( + const data = transaction.contexts?.trace?.data; + expect(data).toEqual( expect.objectContaining({ // the originating client, as reported by the outermost proxy [CLIENT_ADDRESS]: '203.0.113.7', // the immediate peer stays the socket, i.e. the proxy itself [NETWORK_PEER_ADDRESS]: '127.0.0.1', + [NETWORK_PEER_PORT]: expect.any(Number), + }), + ); + // the socket port belongs to the proxy, not the forwarded client + expect(data).not.toHaveProperty(CLIENT_PORT); + }); + + it('falls back to the socket for `client.address` when the forwarding header is not an IP', async () => { + server = http.createServer((_req, res) => res.end('ok')); + await new Promise(resolve => server.listen(0, '127.0.0.1', () => resolve())); + instrument(true); + + await makeRequest('/users/42', 'GET', { 'X-Forwarded-For': 'unknown' }); + const transaction = await waitForTransaction(); + + expect(transaction.contexts?.trace?.data).toEqual( + expect.objectContaining({ + [CLIENT_ADDRESS]: '127.0.0.1', + [CLIENT_PORT]: expect.any(Number), }), ); }); diff --git a/packages/core/test/lib/tracing/errors.test.ts b/packages/core/test/lib/tracing/errors.test.ts index 97afa005410d..b09bb3132732 100644 --- a/packages/core/test/lib/tracing/errors.test.ts +++ b/packages/core/test/lib/tracing/errors.test.ts @@ -1,11 +1,6 @@ +import { SENTRY_STATUS_MESSAGE } from '@sentry/conventions/attributes'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { - SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE, - setCurrentClient, - spanToJSON, - startInactiveSpan, - startSpan, -} from '../../../src'; +import { setCurrentClient, spanToJSON, startInactiveSpan, startSpan } from '../../../src'; import * as globalErrorModule from '../../../src/instrument/globalError'; import * as globalUnhandledRejectionModule from '../../../src/instrument/globalUnhandledRejection'; import { _resetErrorsInstrumented, registerSpanErrorInstrumentation } from '../../../src/tracing/errors'; @@ -67,7 +62,7 @@ describe('registerErrorHandlers()', () => { mockErrorCallback({} as HandlerDataError); const { status, attributes } = spanToJSON(span); expect(status).toBe('error'); - expect(attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBe('internal_error'); + expect(attributes[SENTRY_STATUS_MESSAGE]).toBe('internal_error'); }); }); @@ -78,7 +73,7 @@ describe('registerErrorHandlers()', () => { mockUnhandledRejectionCallback({}); const { status, attributes } = spanToJSON(span); expect(status).toBe('error'); - expect(attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBe('internal_error'); + expect(attributes[SENTRY_STATUS_MESSAGE]).toBe('internal_error'); }); }); }); diff --git a/packages/core/test/lib/tracing/idleSpan.test.ts b/packages/core/test/lib/tracing/idleSpan.test.ts index 83f72e45c42f..3809db713668 100644 --- a/packages/core/test/lib/tracing/idleSpan.test.ts +++ b/packages/core/test/lib/tracing/idleSpan.test.ts @@ -7,8 +7,6 @@ import { getDynamicSamplingContextFromSpan, getIsolationScope, getTraceData, - SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, - SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE, SentryNonRecordingSpan, SentrySpan, setCurrentClient, @@ -23,7 +21,11 @@ import type { Event } from '../../../src/types/event'; import type { Span } from '../../../src/types/span'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; import { resetGlobals } from '../../testutils'; -import { SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; +import { + SENTRY_IDLE_SPAN_FINISH_REASON, + SENTRY_SEGMENT_NAME_SOURCE, + SENTRY_STATUS_MESSAGE, +} from '@sentry/conventions/attributes'; const dsn = 'https://123@sentry.io/42'; @@ -518,9 +520,7 @@ describe('startIdleSpan', () => { vi.runOnlyPendingTimers(); expect(beforeSendTransaction).toHaveBeenCalledTimes(1); - expect(transaction?.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]).toEqual( - 'externalFinish', - ); + expect(transaction?.contexts?.trace?.data?.[SENTRY_IDLE_SPAN_FINISH_REASON]).toEqual('externalFinish'); }); it('sets finish reason when span ends', () => { @@ -539,9 +539,7 @@ describe('startIdleSpan', () => { vi.runOnlyPendingTimers(); expect(beforeSendTransaction).toHaveBeenCalledTimes(1); - expect(transaction?.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]).toEqual( - 'idleTimeout', - ); + expect(transaction?.contexts?.trace?.data?.[SENTRY_IDLE_SPAN_FINISH_REASON]).toEqual('idleTimeout'); }); it('sets finish reason when span ends via expired heartbeat timeout', () => { @@ -560,9 +558,7 @@ describe('startIdleSpan', () => { vi.runOnlyPendingTimers(); expect(beforeSendTransaction).toHaveBeenCalledTimes(1); - expect(transaction?.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]).toEqual( - 'heartbeatFailed', - ); + expect(transaction?.contexts?.trace?.data?.[SENTRY_IDLE_SPAN_FINISH_REASON]).toEqual('heartbeatFailed'); }); it('sets finish reason when span ends via final timeout', () => { @@ -590,9 +586,7 @@ describe('startIdleSpan', () => { vi.runOnlyPendingTimers(); expect(beforeSendTransaction).toHaveBeenCalledTimes(1); - expect(transaction?.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]).toEqual( - 'finalTimeout', - ); + expect(transaction?.contexts?.trace?.data?.[SENTRY_IDLE_SPAN_FINISH_REASON]).toEqual('finalTimeout'); }); it('uses finish reason set outside when span ends', () => { @@ -607,14 +601,12 @@ describe('startIdleSpan', () => { client.init(); const span = startIdleSpan({ name: 'foo' }); - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, 'custom reason'); + span.setAttribute(SENTRY_IDLE_SPAN_FINISH_REASON, 'custom reason'); startSpan({ name: 'inner' }, () => {}); vi.runOnlyPendingTimers(); expect(beforeSendTransaction).toHaveBeenCalledTimes(1); - expect(transaction?.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]).toEqual( - 'custom reason', - ); + expect(transaction?.contexts?.trace?.data?.[SENTRY_IDLE_SPAN_FINISH_REASON]).toEqual('custom reason'); }); describe('idleTimeout', () => { @@ -677,23 +669,17 @@ describe('startIdleSpan', () => { // Start any span to cancel idle timeout startInactiveSpan({ name: 'span' }); - expect(spanToJSON(idleSpan).attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).not.toEqual( - 'deadline_exceeded', - ); + expect(spanToJSON(idleSpan).attributes[SENTRY_STATUS_MESSAGE]).not.toEqual('deadline_exceeded'); expect(spanToJSON(idleSpan).end_timestamp).toBeUndefined(); // Wait some time vi.advanceTimersByTime(TRACING_DEFAULTS.childSpanTimeout - 1000); - expect(spanToJSON(idleSpan).attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).not.toEqual( - 'deadline_exceeded', - ); + expect(spanToJSON(idleSpan).attributes[SENTRY_STATUS_MESSAGE]).not.toEqual('deadline_exceeded'); expect(spanToJSON(idleSpan).end_timestamp).toBeUndefined(); // Wait for timeout to exceed vi.advanceTimersByTime(1000); - expect(spanToJSON(idleSpan).attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).not.toEqual( - 'deadline_exceeded', - ); + expect(spanToJSON(idleSpan).attributes[SENTRY_STATUS_MESSAGE]).not.toEqual('deadline_exceeded'); expect(spanToJSON(idleSpan).end_timestamp).toBeDefined(); }); @@ -704,41 +690,31 @@ describe('startIdleSpan', () => { // Start any span to cancel idle timeout startInactiveSpan({ name: 'span' }); - expect(spanToJSON(idleSpan).attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).not.toEqual( - 'deadline_exceeded', - ); + expect(spanToJSON(idleSpan).attributes[SENTRY_STATUS_MESSAGE]).not.toEqual('deadline_exceeded'); expect(spanToJSON(idleSpan).end_timestamp).toBeUndefined(); // Wait some time vi.advanceTimersByTime(TRACING_DEFAULTS.childSpanTimeout - 1000); - expect(spanToJSON(idleSpan).attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).not.toEqual( - 'deadline_exceeded', - ); + expect(spanToJSON(idleSpan).attributes[SENTRY_STATUS_MESSAGE]).not.toEqual('deadline_exceeded'); expect(spanToJSON(idleSpan).end_timestamp).toBeUndefined(); // New span resets the timeout startInactiveSpan({ name: 'span' }); vi.advanceTimersByTime(TRACING_DEFAULTS.childSpanTimeout - 1000); - expect(spanToJSON(idleSpan).attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).not.toEqual( - 'deadline_exceeded', - ); + expect(spanToJSON(idleSpan).attributes[SENTRY_STATUS_MESSAGE]).not.toEqual('deadline_exceeded'); expect(spanToJSON(idleSpan).end_timestamp).toBeUndefined(); // New span resets the timeout startInactiveSpan({ name: 'span' }); vi.advanceTimersByTime(TRACING_DEFAULTS.childSpanTimeout - 1000); - expect(spanToJSON(idleSpan).attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).not.toEqual( - 'deadline_exceeded', - ); + expect(spanToJSON(idleSpan).attributes[SENTRY_STATUS_MESSAGE]).not.toEqual('deadline_exceeded'); expect(spanToJSON(idleSpan).end_timestamp).toBeUndefined(); // Wait for timeout to exceed vi.advanceTimersByTime(1000); - expect(spanToJSON(idleSpan).attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).not.toEqual( - 'deadline_exceeded', - ); + expect(spanToJSON(idleSpan).attributes[SENTRY_STATUS_MESSAGE]).not.toEqual('deadline_exceeded'); expect(spanToJSON(idleSpan).end_timestamp).toBeDefined(); }); @@ -751,9 +727,7 @@ describe('startIdleSpan', () => { // Wait some time vi.advanceTimersByTime(TRACING_DEFAULTS.childSpanTimeout - 1000); - expect(spanToJSON(idleSpan).attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).not.toEqual( - 'deadline_exceeded', - ); + expect(spanToJSON(idleSpan).attributes[SENTRY_STATUS_MESSAGE]).not.toEqual('deadline_exceeded'); expect(spanToJSON(idleSpan).end_timestamp).toBeUndefined(); // new standalone span should not reset the timeout @@ -762,9 +736,7 @@ describe('startIdleSpan', () => { // Wait for timeout to exceed vi.advanceTimersByTime(1001); - expect(spanToJSON(idleSpan).attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).not.toEqual( - 'deadline_exceeded', - ); + expect(spanToJSON(idleSpan).attributes[SENTRY_STATUS_MESSAGE]).not.toEqual('deadline_exceeded'); expect(spanToJSON(idleSpan).end_timestamp).toBeDefined(); }); }); diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index 24a45b0eb561..06f83bea6b0a 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -3,16 +3,9 @@ import type { Contexts, Span, StreamedSpanJSON } from '../../../../src'; import { captureSpan, debug, - SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, - SEMANTIC_ATTRIBUTE_USER_EMAIL, - SEMANTIC_ATTRIBUTE_USER_ID, - SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, - SEMANTIC_ATTRIBUTE_USER_USERNAME, spanStreamingIntegration, startInactiveSpan, startSpan, @@ -33,6 +26,13 @@ import { SENTRY_SEGMENT_NAME, SENTRY_SEGMENT_NAME_SOURCE, SENTRY_TRACE_LIFECYCLE, + USER_EMAIL, + USER_ID, + USER_IP_ADDRESS, + USER_NAME, + SENTRY_ENVIRONMENT, + SENTRY_RELEASE, + SENTRY_SDK_INTEGRATIONS, } from '@sentry/conventions/attributes'; describe('captureSpan', () => { @@ -101,27 +101,27 @@ describe('captureSpan', () => { value: 'custom', type: 'string', }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { + [SENTRY_RELEASE]: { value: '1.0.0', type: 'string', }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + [SENTRY_ENVIRONMENT]: { value: 'staging', type: 'string', }, - [SEMANTIC_ATTRIBUTE_USER_ID]: { + [USER_ID]: { value: '123', type: 'string', }, - [SEMANTIC_ATTRIBUTE_USER_EMAIL]: { + [USER_EMAIL]: { value: 'user@example.com', type: 'string', }, - [SEMANTIC_ATTRIBUTE_USER_USERNAME]: { + [USER_NAME]: { value: 'testuser', type: 'string', }, - [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: { + [USER_IP_ADDRESS]: { value: '127.0.0.1', type: 'string', }, @@ -197,11 +197,11 @@ describe('captureSpan', () => { value: 'custom', type: 'string', }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { + [SENTRY_RELEASE]: { value: '1.0.0', type: 'string', }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + [SENTRY_ENVIRONMENT]: { value: 'staging', type: 'string', }, @@ -217,19 +217,19 @@ describe('captureSpan', () => { value: '1.0.0', type: 'string', }, - [SEMANTIC_ATTRIBUTE_USER_ID]: { + [USER_ID]: { value: '123', type: 'string', }, - [SEMANTIC_ATTRIBUTE_USER_EMAIL]: { + [USER_EMAIL]: { value: 'user@example.com', type: 'string', }, - [SEMANTIC_ATTRIBUTE_USER_USERNAME]: { + [USER_NAME]: { value: 'testuser', type: 'string', }, - [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: { + [USER_IP_ADDRESS]: { value: '127.0.0.1', type: 'string', }, @@ -293,11 +293,11 @@ describe('captureSpan', () => { value: 'custom', type: 'string', }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { + [SENTRY_RELEASE]: { value: '1.0.0', type: 'string', }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + [SENTRY_ENVIRONMENT]: { value: 'production', type: 'string', }, @@ -356,11 +356,11 @@ describe('captureSpan', () => { [SENTRY_SEGMENT_NAME]: { value: 'my-span', type: 'string' }, [SENTRY_SEGMENT_ID]: { value: span.spanContext().spanId, type: 'string' }, [SENTRY_SEGMENT_NAME_SOURCE]: { value: 'custom', type: 'string' }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { value: '1.0.0', type: 'string' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'staging', type: 'string' }, + [SENTRY_RELEASE]: { value: '1.0.0', type: 'string' }, + [SENTRY_ENVIRONMENT]: { value: 'staging', type: 'string' }, [SENTRY_SDK_NAME]: { value: 'sentry.javascript.browser', type: 'string' }, [SENTRY_SDK_VERSION]: { value: '9.0.0', type: 'string' }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: { + [SENTRY_SDK_INTEGRATIONS]: { type: 'array', value: ['EventFilters', 'BrowserTracing'], }, @@ -389,7 +389,7 @@ describe('captureSpan', () => { }); expect(serializedChild.is_segment).toBe(false); - expect(serializedChild.attributes[SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]).toBeUndefined(); + expect(serializedChild.attributes[SENTRY_SDK_INTEGRATIONS]).toBeUndefined(); }); describe('client hooks', () => { diff --git a/packages/core/test/lib/tracing/spanstatus.test.ts b/packages/core/test/lib/tracing/spanstatus.test.ts index 9d37b033a654..3abb6ee84619 100644 --- a/packages/core/test/lib/tracing/spanstatus.test.ts +++ b/packages/core/test/lib/tracing/spanstatus.test.ts @@ -1,5 +1,6 @@ +import { SENTRY_STATUS_MESSAGE } from '@sentry/conventions/attributes'; import { describe, expect, it } from 'vitest'; -import { SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE, SentrySpan, setHttpStatus, spanToJSON } from '../../../src/index'; +import { SentrySpan, setHttpStatus, spanToJSON } from '../../../src/index'; describe('setHttpStatus', () => { it.each([ @@ -24,7 +25,7 @@ describe('setHttpStatus', () => { const { status: spanStatus, attributes } = spanToJSON(span); expect(spanStatus).toBe(status); - expect(attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBe(statusMessage); + expect(attributes[SENTRY_STATUS_MESSAGE]).toBe(statusMessage); expect(attributes).toMatchObject({ 'http.response.status_code': code }); }); @@ -36,7 +37,7 @@ describe('setHttpStatus', () => { const { status: spanStatus, attributes } = spanToJSON(span); expect(spanStatus).toBe('error'); - expect(attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBe('internal_error'); + expect(attributes[SENTRY_STATUS_MESSAGE]).toBe('internal_error'); expect(attributes).toMatchObject({ 'http.response.status_code': 600 }); }); }); diff --git a/packages/core/test/lib/utils/clientIPAddress.test.ts b/packages/core/test/lib/utils/clientIPAddress.test.ts new file mode 100644 index 000000000000..c19c090752ec --- /dev/null +++ b/packages/core/test/lib/utils/clientIPAddress.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { getClientIPAddress } from '../../../src/utils/clientIPAddress'; + +describe('getClientIPAddress', () => { + it.each([ + ['203.0.113.7', '203.0.113.7'], + ['203.0.113.7:4711', '203.0.113.7'], + ['unknown, 203.0.113.7:4711', '203.0.113.7'], + ['[2001:db8::1]:4711', '2001:db8::1'], + ['[2001:db8::1]', '2001:db8::1'], + ['2001:db8::1', '2001:db8::1'], + ['unknown', null], + ])('parses the X-Forwarded-For value %s', (headerValue, expectedIP) => { + expect(getClientIPAddress({ 'X-Forwarded-For': headerValue })).toBe(expectedIP); + }); + + it.each([ + ['for=203.0.113.7', '203.0.113.7'], + ['For=203.0.113.7', '203.0.113.7'], + ['proto=https;for=203.0.113.7', '203.0.113.7'], + ['for="203.0.113.7:4711"', '203.0.113.7'], + ['for="[2001:db8::1]:4711"', '2001:db8::1'], + ['for=203.0.113.7;proto=https, for=198.51.100.1', '203.0.113.7'], + ['proto=https', null], + ['for=_hidden', null], + ])('parses the Forwarded value %s', (headerValue, expectedIP) => { + expect(getClientIPAddress({ Forwarded: headerValue })).toBe(expectedIP); + }); + + it('joins array header values', () => { + expect(getClientIPAddress({ 'x-forwarded-for': ['unknown', '203.0.113.7:4711'] })).toBe('203.0.113.7'); + }); + + it('keeps the header priority order', () => { + expect(getClientIPAddress({ 'X-Real-IP': '198.51.100.1', 'X-Forwarded-For': '203.0.113.7:4711' })).toBe( + '203.0.113.7', + ); + }); +}); diff --git a/packages/core/test/lib/utils/spanUtils.test.ts b/packages/core/test/lib/utils/spanUtils.test.ts index c31d4e432b97..e8ae4379b86f 100644 --- a/packages/core/test/lib/utils/spanUtils.test.ts +++ b/packages/core/test/lib/utils/spanUtils.test.ts @@ -1,3 +1,4 @@ +import { SENTRY_LINK_TYPE, SENTRY_STATUS_MESSAGE } from '@sentry/conventions/attributes'; import { beforeEach, describe, expect, it, test } from 'vitest'; import { convertSpanLinksForEnvelope, @@ -5,8 +6,6 @@ import { Scope, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE, - SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE, SentryNonRecordingSpan, SentrySpan, setCurrentClient, @@ -498,7 +497,7 @@ describe('spanToStaticSpanJSON', () => { trace_id: 'trace1', sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }, ], @@ -510,7 +509,7 @@ describe('spanToStaticSpanJSON', () => { const json = spanToJSON(span); expect(json.status).toBe('error'); - expect(json.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBe('Connection Refused'); + expect(json.attributes?.[SENTRY_STATUS_MESSAGE]).toBe('Connection Refused'); }); it('does not set a status message for ok spans', () => { @@ -519,7 +518,7 @@ describe('spanToStaticSpanJSON', () => { const json = spanToJSON(span); expect(json.status).toBe('ok'); - expect(json.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBeUndefined(); + expect(json.attributes?.[SENTRY_STATUS_MESSAGE]).toBeUndefined(); }); it('does not set a status message for error spans without a message', () => { @@ -528,7 +527,7 @@ describe('spanToStaticSpanJSON', () => { const json = spanToJSON(span); expect(json.status).toBe('error'); - expect(json.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBeUndefined(); + expect(json.attributes?.[SENTRY_STATUS_MESSAGE]).toBeUndefined(); }); it('treats a cancelled status as ok and does not set a status message', () => { @@ -537,18 +536,18 @@ describe('spanToStaticSpanJSON', () => { const json = spanToJSON(span); expect(json.status).toBe('ok'); - expect(json.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBeUndefined(); + expect(json.attributes?.[SENTRY_STATUS_MESSAGE]).toBeUndefined(); }); it('does not overwrite an explicitly set sentry.status.message attribute', () => { const span = new SentrySpan({ name: 'test name', - attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]: 'explicit message' }, + attributes: { [SENTRY_STATUS_MESSAGE]: 'explicit message' }, }); span.setStatus({ code: SPAN_STATUS_ERROR, message: 'Connection Refused' }); const json = spanToJSON(span); - expect(json.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBe('explicit message'); + expect(json.attributes?.[SENTRY_STATUS_MESSAGE]).toBe('explicit message'); }); }); describe('OpenTelemetry Span', () => { @@ -598,7 +597,7 @@ describe('spanToStaticSpanJSON', () => { traceFlags: TRACE_FLAG_SAMPLED, }, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }, ], @@ -619,7 +618,7 @@ describe('spanToStaticSpanJSON', () => { attr2: 2, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'test op', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', - [SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]: 'unknown_error', + [SENTRY_STATUS_MESSAGE]: 'unknown_error', }, links: [ { @@ -627,7 +626,7 @@ describe('spanToStaticSpanJSON', () => { trace_id: 'trace1', sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }, ], @@ -647,7 +646,7 @@ describe('spanToStaticSpanJSON', () => { const json = spanToJSON(span); expect(json.status).toBe('error'); - expect(json.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBe('Connection Refused'); + expect(json.attributes?.[SENTRY_STATUS_MESSAGE]).toBe('Connection Refused'); }); it('does not set a status message for ok/unset spans', () => { @@ -663,7 +662,7 @@ describe('spanToStaticSpanJSON', () => { const json = spanToJSON(span); expect(json.status).toBe('ok'); - expect(json.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBeUndefined(); + expect(json.attributes?.[SENTRY_STATUS_MESSAGE]).toBeUndefined(); }); }); }); @@ -693,7 +692,7 @@ describe('spanToStaticSpanJSON', () => { trace_id: 'trace1', sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }, ], @@ -722,7 +721,7 @@ describe('spanToStaticSpanJSON', () => { trace_id: 'trace1', sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { type: 'string', value: 'previous_trace' }, + [SENTRY_LINK_TYPE]: { type: 'string', value: 'previous_trace' }, }, }, ], diff --git a/packages/core/test/lib/utils/transactionEvent.test.ts b/packages/core/test/lib/utils/transactionEvent.test.ts index 64a772a9b67a..03f1e231d975 100644 --- a/packages/core/test/lib/utils/transactionEvent.test.ts +++ b/packages/core/test/lib/utils/transactionEvent.test.ts @@ -1,5 +1,5 @@ +import { SENTRY_PROFILE_ID, SENTRY_EXCLUSIVE_TIME } from '@sentry/conventions/attributes'; import { describe, expect, it } from 'vitest'; -import { SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, SEMANTIC_ATTRIBUTE_PROFILE_ID } from '../../../src/semanticAttributes'; import type { TransactionEvent } from '../../../src/types/event'; import type { SpanJSON } from '../../../src/types/span'; import { @@ -44,8 +44,8 @@ describe('convertTransactionEventToSpanJson', () => { origin: 'manual', op: 'http', data: { - [SEMANTIC_ATTRIBUTE_PROFILE_ID]: 'profile123', - [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 123.45, + [SENTRY_PROFILE_ID]: 'profile123', + [SENTRY_EXCLUSIVE_TIME]: 123.45, other: 'value', }, }, @@ -59,8 +59,8 @@ describe('convertTransactionEventToSpanJson', () => { expect(convertTransactionEventToSpanJson(event)).toEqual({ data: { - [SEMANTIC_ATTRIBUTE_PROFILE_ID]: 'profile123', - [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 123.45, + [SENTRY_PROFILE_ID]: 'profile123', + [SENTRY_EXCLUSIVE_TIME]: 123.45, other: 'value', }, description: 'Test Transaction', @@ -162,8 +162,8 @@ describe('convertSpanJsonToTransactionEvent', () => { origin: 'manual', data: { other: 'value', - [SEMANTIC_ATTRIBUTE_PROFILE_ID]: 'profile123', - [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 123.45, + [SENTRY_PROFILE_ID]: 'profile123', + [SENTRY_EXCLUSIVE_TIME]: 123.45, }, }, }, diff --git a/packages/deno/package.json b/packages/deno/package.json index 854e8bd2d386..57bcedb91703 100644 --- a/packages/deno/package.json +++ b/packages/deno/package.json @@ -28,7 +28,7 @@ ], "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "@sentry/server-runtime-injection": "11.2.0", "@sentry/server-utils": "11.2.0" diff --git a/packages/deno/src/wrap-deno-request-handler.ts b/packages/deno/src/wrap-deno-request-handler.ts index 1d53cca0db27..1ab390d3790b 100644 --- a/packages/deno/src/wrap-deno-request-handler.ts +++ b/packages/deno/src/wrap-deno-request-handler.ts @@ -24,6 +24,7 @@ import { winterCGRequestToRequestData, withIsolationScope, } from '@sentry/core'; +import { getClientIPAddress } from '@sentry/core/server'; import { streamResponse } from './utils/streaming'; export type RequestHandlerWrapperOptions = { @@ -93,24 +94,32 @@ export const wrapDenoRequestHandler = ( assignIfSet(attributes, 'http.request.body.size', contentLength && parseInt(contentLength, 10)); assignIfSet(attributes, 'user_agent.original', request.headers.get('user-agent')); + const headers = winterCGHeadersToDict(request.headers); + const socketIp = (info?.remoteAddr as Deno.NetAddr)?.hostname; const dataCollection = client.getDataCollectionOptions(); if (dataCollection.userInfo) { // `client.address` is the originating client, so a forwarding header wins over the socket, which - // behind a proxy holds the proxy's address. - const forwardedFor = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim(); - const socketAddress = (info?.remoteAddr as Deno.NetAddr)?.hostname ?? (info?.remoteAddr as Deno.UnixAddr)?.path; - const clientPort = (info?.remoteAddr as Deno.NetAddr)?.port; - assignIfSet(attributes, CLIENT_ADDRESS, forwardedFor || socketAddress); - assignIfSet(attributes, CLIENT_PORT, clientPort); + // behind a proxy holds the proxy's address. The socket port is the proxy's too, so `client.port` + // stays unset then. + const forwardedAddress = getClientIPAddress(headers); + assignIfSet( + attributes, + CLIENT_ADDRESS, + forwardedAddress || socketIp || (info?.remoteAddr as Deno.UnixAddr)?.path, + ); + if (!forwardedAddress) { + assignIfSet(attributes, CLIENT_PORT, (info?.remoteAddr as Deno.NetAddr)?.port); + } } // describes the OSI application-layer protocol (http), not the scheme (might be https) attributes[NETWORK_PROTOCOL_NAME] = 'http'; - Object.assign(attributes, httpHeadersToSpanAttributes(winterCGHeadersToDict(request.headers), dataCollection)); + Object.assign(attributes, httpHeadersToSpanAttributes(headers, dataCollection)); attributes[SENTRY_OP] = HTTP_SERVER; isolationScope.setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(request), + ipAddress: socketIp, }); const configuredBodySize = client.getIntegrationByName( diff --git a/packages/deno/test/deno-serve.test.ts b/packages/deno/test/deno-serve.test.ts index 933aded91ee8..f85e596e631a 100644 --- a/packages/deno/test/deno-serve.test.ts +++ b/packages/deno/test/deno-serve.test.ts @@ -2,10 +2,12 @@ import type { ErrorEvent, TransactionEvent } from '@sentry/core'; import { getMainCarrier } from '@sentry/core'; -import { assertEquals, assertExists, assertNotEquals } from 'https://deno.land/std@0.212.0/assert/mod.ts'; +import { assertEquals, assertExists, assertMatch, assertNotEquals } from 'https://deno.land/std@0.212.0/assert/mod.ts'; import type { DenoClient } from '../build/esm/index.js'; import { captureException, captureMessage, init, denoServeIntegration, setTag, setUser } from '../build/esm/index.js'; +const LOOPBACK_ADDRESS = /^(127\.0\.0\.1|::1|::ffff:127\.0\.0\.1)$/; + function resetGlobals(): void { getMainCarrier().__SENTRY__ = undefined; } @@ -552,6 +554,113 @@ Deno.test('Deno.serve should capture client address and port by default', async assertExists(transaction?.contexts?.trace?.data?.['client.port']); }); +Deno.test('Deno.serve should prefer the forwarded client address, without the socket port', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined = undefined; + const p = new Promise(resolve => (onListen = resolve)); + const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, () => { + return new Response('OK'); + }); + await p; + + const res = await fetch(`http://localhost:${server.addr.port}/test`, { + headers: { 'X-Forwarded-For': '203.0.113.7, 10.0.0.1' }, + }); + assertEquals(await res.text(), 'OK'); + + abortController.abort(); + await server.finished; + + assertEquals(transactionEvents.length, 1); + const [transaction] = transactionEvents; + + assertEquals(transaction?.contexts?.trace?.data?.['client.address'], '203.0.113.7'); + assertEquals(transaction?.contexts?.trace?.data?.['client.port'], undefined); +}); + +Deno.test('Deno.serve should fall back to the socket address when the forwarding header is not an IP', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined = undefined; + const p = new Promise(resolve => (onListen = resolve)); + const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, () => { + return new Response('OK'); + }); + await p; + + const res = await fetch(`http://localhost:${server.addr.port}/test`, { + headers: { 'X-Forwarded-For': 'unknown' }, + }); + assertEquals(await res.text(), 'OK'); + + abortController.abort(); + await server.finished; + + assertEquals(transactionEvents.length, 1); + const [transaction] = transactionEvents; + + assertMatch(String(transaction?.contexts?.trace?.data?.['client.address']), LOOPBACK_ADDRESS); + assertEquals(typeof transaction?.contexts?.trace?.data?.['client.port'], 'number'); +}); + +Deno.test('Deno.serve should set the socket address as the user IP on error events', async () => { + resetGlobals(); + const errorEvents: ErrorEvent[] = []; + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + beforeSend: (event: ErrorEvent) => { + errorEvents.push(event); + return null; + }, + }) as DenoClient; + + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined = undefined; + const p = new Promise(resolve => (onListen = resolve)); + const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, () => { + captureException(new Error('Boom')); + return new Response('OK'); + }); + await p; + + const res = await fetch(`http://localhost:${server.addr.port}/test`); + assertEquals(await res.text(), 'OK'); + + abortController.abort(); + await server.finished; + + assertEquals(errorEvents.length, 1); + assertMatch(String(errorEvents[0]?.user?.ip_address), LOOPBACK_ADDRESS); +}); + Deno.test('Deno.serve should not capture client address when userInfo collection is disabled', async () => { resetGlobals(); const transactionEvents: TransactionEvent[] = []; diff --git a/packages/effect/package.json b/packages/effect/package.json index c1e3b86c2eb4..b04c7c72e252 100644 --- a/packages/effect/package.json +++ b/packages/effect/package.json @@ -45,12 +45,12 @@ }, "dependencies": { "@sentry/browser": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "@sentry/node": "11.2.0" }, "peerDependencies": { - "effect": "^3.0.0 || ^4.0.0-beta.50" + "effect": "^3.0.0 || ^4.0.0" }, "peerDependenciesMeta": { "effect": { @@ -58,8 +58,8 @@ } }, "devDependencies": { - "@effect/vitest": "^4.0.0-beta.50", - "effect": "^4.0.0-beta.50" + "@effect/vitest": "^4.0.0", + "effect": "^4.0.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/effect/src/client/tracer.ts b/packages/effect/src/client/tracer.ts index e824286ddf60..e4313b7fa927 100644 --- a/packages/effect/src/client/tracer.ts +++ b/packages/effect/src/client/tracer.ts @@ -8,4 +8,4 @@ import { makeSentryTracer } from '../tracer'; * keeps the browser variant substitutable (mocks, bundler interop) exactly as it was when the tracer * called it directly. */ -export const SentryEffectTracer = makeSentryTracer(options => startInactiveSpan(options)); +export const SentryEffectTracer = makeSentryTracer(options => startInactiveSpan(options), false); diff --git a/packages/effect/src/index.client.ts b/packages/effect/src/index.client.ts index 76aba91e5d04..14ad2597abdf 100644 --- a/packages/effect/src/index.client.ts +++ b/packages/effect/src/index.client.ts @@ -7,5 +7,6 @@ export { effectLayer, init } from './client/index'; export type { EffectClientLayerOptions } from './client/index'; export { SentryEffectTracer } from './client/tracer'; +export { SentryEffectExternalSpanLayer } from './tracer'; export { SentryEffectLogger } from './logger'; export { SentryEffectMetricsLayer } from './metrics'; diff --git a/packages/effect/src/index.server.ts b/packages/effect/src/index.server.ts index 01d9272ce6a9..6ceec3346390 100644 --- a/packages/effect/src/index.server.ts +++ b/packages/effect/src/index.server.ts @@ -4,5 +4,6 @@ export { effectLayer, init } from './server/index'; export type { EffectServerLayerOptions } from './server/index'; export { SentryEffectTracer } from './server/tracer'; +export { SentryEffectExternalSpanLayer } from './tracer'; export { SentryEffectLogger } from './logger'; export { SentryEffectMetricsLayer } from './metrics'; diff --git a/packages/effect/src/server/tracer.ts b/packages/effect/src/server/tracer.ts index d4b622c2ba5c..0814db123343 100644 --- a/packages/effect/src/server/tracer.ts +++ b/packages/effect/src/server/tracer.ts @@ -8,6 +8,10 @@ import { makeSentryTracer } from '../tracer'; * every span start with a `getClient()` lookup to lazily install `spanStreamingIntegration`, which on * the server is pure overhead — `ServerRuntimeClient` already installs it eagerly. * + * Every parentless Effect span starts a new trace. A server process forks no propagation context on + * its own, so without this every request, reactor and background job of the process would land in + * one trace. + * * See `./client/tracer.ts` for why the call is wrapped in an arrow. */ -export const SentryEffectTracer = makeSentryTracer(options => startInactiveSpan(options)); +export const SentryEffectTracer = makeSentryTracer(options => startInactiveSpan(options), true); diff --git a/packages/effect/src/tracer.ts b/packages/effect/src/tracer.ts index 08157a6e3a89..2e72e3c61fc0 100644 --- a/packages/effect/src/tracer.ts +++ b/packages/effect/src/tracer.ts @@ -1,9 +1,23 @@ +/* oxlint-disable max-lines */ import { SENTRY_OP } from '@sentry/conventions/attributes'; import { HTTP_CLIENT, HTTP_SERVER } from '@sentry/conventions/op'; import type { Span, StartSpanOptions } from '@sentry/core'; -import { isObjectLike, getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, withActiveSpan } from '@sentry/core'; -import type * as Context from 'effect/Context'; +import { + _INTERNAL_safeMathRandom, + addNonEnumerableProperty, + getActiveSpan, + getCurrentScope, + getDefaultCurrentScope, + isObjectLike, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startNewTrace, + withActiveSpan, + withScope, +} from '@sentry/core'; +import * as Context from 'effect/Context'; import * as Exit from 'effect/Exit'; +import type * as EffectLayer from 'effect/Layer'; +import { succeed as succeedLayer } from 'effect/Layer'; import * as Option from 'effect/Option'; import * as EffectTracer from 'effect/Tracer'; @@ -15,6 +29,20 @@ function deriveOrigin(name: string): string | undefined { return undefined; } +const EFFECT_SPAN_SYMBOL = Symbol.for('@sentry/effect.EffectSpan'); + +function markEffectSpan(span: Span): void { + addNonEnumerableProperty(span, EFFECT_SPAN_SYMBOL, true); +} + +/** + * Whether this tracer created the span. A brand rather than an attribute check, because an unsampled + * span keeps no attributes. + */ +function isEffectSpan(span: Span): boolean { + return (span as { [EFFECT_SPAN_SYMBOL]?: boolean })[EFFECT_SPAN_SYMBOL] === true; +} + /** * Effect span names are chosen by whoever calls `Effect.withSpan`, so the name is the only signal * available. `@effect/platform` names its HTTP spans `http.server`/`http.client`, which map onto the @@ -173,42 +201,12 @@ class SentrySpanWrapper implements SentrySpanLike { } /** - * The client and the server entry differ only in which `startInactiveSpan` they hand to - * {@link makeSentryTracer}: the browser one from `@sentry/core`, which installs the span - * streaming integration on first use, and the plain one from `@sentry/core`, which does not. Nothing - * else about the tracer is platform-specific. + * The client and the server entry hand different `startInactiveSpan` functions to + * {@link makeSentryTracer}: the browser one from `@sentry/core/browser`, which installs the span + * streaming integration on first use, and the plain one from `@sentry/core`, which does not. */ export type StartInactiveSpan = (options: StartSpanOptions) => Span; -function createSentrySpan( - startInactiveSpan: StartInactiveSpan, - name: string, - parent: Option.Option, - context: Context.Context, - links: ReadonlyArray, - startTime: bigint, - kind: EffectTracer.SpanKind, -): SentrySpanLike { - const parentSentrySpan = - Option.isSome(parent) && isSentrySpan(parent.value) ? parent.value.sentrySpan : (getActiveSpan() ?? null); - - const op = deriveOp(name); - const origin = deriveOrigin(name); - - const newSpan = startInactiveSpan({ - name, - startTime: nanosToHrTime(startTime), - // Setting these to `undefined` would strip the core defaults instead of leaving them in place. - attributes: { - ...(op && { [SENTRY_OP]: op }), - ...(origin && { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin }), - }, - ...(parentSentrySpan ? { parentSpan: parentSentrySpan } : {}), - }); - - return new SentrySpanWrapper(name, parent, context, links, startTime, kind, newSpan); -} - // Check if we're running Effect v4 by checking the Exit/Cause structure // In v4, causes have a 'reasons' array // In v3, causes have '_tag' directly on the cause object @@ -226,7 +224,187 @@ const isEffectV4 = (() => { } })(); -const makeSentryTracerV3 = (startInactiveSpan: StartInactiveSpan): EffectTracer.Tracer => { +const EXTERNAL_SPAN_KEY = '@sentry/effect/ExternalSpan'; + +interface ContextExports { + Reference: typeof Context.Reference; + /** Only Effect v3 exports it. */ + GenericTag(key: string): Context.Key; +} + +// Effect v4 has no `Context.GenericTag` and Effect v3 before 3.11 has no `Context.Reference`. Both are read +// through a separate binding because bundlers fail the build on a namespace member the installed version lacks. +const contextExports = Context as unknown as ContextExports; + +/** + * Whether the tracer continues the trace of a `Tracer.externalSpan` parent. Effect v4 has no `FiberRef` + * and v3 before 3.11 has no `Context.Reference`, so the flag is a plain service in both. The cast is safe: + * the tracer reads it with `getRef` only on a v4 fiber, where it is a reference, and with + * `Context.getOption` on v3. + */ +const ExternalSpanFlag = ( + isEffectV4 + ? contextExports.Reference(EXTERNAL_SPAN_KEY, { defaultValue: () => false }) + : contextExports.GenericTag(EXTERNAL_SPAN_KEY) +) as Context.Reference; + +/** + * Makes the tracer continue the trace of a `Tracer.externalSpan` parent instead of ignoring it. Provide it + * next to the tracer layer to continue every external span in the runtime, or provide it to a single + * effect with `Effect.provide` to continue only that one. + */ +export const SentryEffectExternalSpanLayer: EffectLayer.Layer = succeedLayer(ExternalSpanFlag, true); + +interface FiberLike { + /** The span the fiber runs in. Only Effect v3 fibers have it. */ + readonly currentSpan?: EffectTracer.AnySpan | undefined; + /** Holds `span`, the span the fiber runs in. Only Effect v4 fibers have it. */ + readonly cache?: { readonly span?: EffectTracer.AnySpan | undefined }; + /** Reads a reference with its default. Only Effect v4 fibers have it. */ + readonly getRef?: (ref: Context.Reference) => A; + /** The services of the fiber. Only Effect v3 fibers have it. */ + readonly currentContext?: Context.Context; +} + +/** + * The fiber whose operation is being evaluated. Effect hands the fiber to the `context` hook but not to + * `span`, which runs synchronously inside it, so the hook keeps the fiber here for that extent. + */ +let currentFiber: FiberLike | undefined; + +function continuesExternalSpans(fiber: FiberLike): boolean { + if (fiber.getRef) { + return fiber.getRef(ExternalSpanFlag); + } + + return ( + fiber.currentContext !== undefined && + Option.getOrElse(Context.getOption(fiber.currentContext, ExternalSpanFlag), () => false) + ); +} + +function getFiberSpan(fiber: FiberLike): EffectTracer.AnySpan | undefined { + return isEffectV4 ? fiber.cache?.span : fiber.currentSpan; +} + +function withFiberContext(fiber: FiberLike, execution: () => X): X { + const previousFiber = currentFiber; + currentFiber = fiber; + try { + const currentSpan = getFiberSpan(fiber); + if (currentSpan === undefined || !isSentrySpan(currentSpan)) { + return execution(); + } + return withActiveSpan(currentSpan.sentrySpan, execution); + } finally { + currentFiber = previousFiber; + } +} + +/** + * Starts the Sentry span for an Effect span, rooted or parented the way Effect asked for. + * + * - A parent this tracer created becomes the Sentry parent. + * - Any other parent (`Tracer.externalSpan` bridging a trace this SDK did not start, such as an + * OpenTelemetry span of another app in the same process or persisted trace state) is ignored unless + * {@link SentryEffectExternalSpanLayer} is provided, so a span joins a foreign trace only when the user + * asked for it. The span then nests where Effect would have put it without the `parent` option: under + * the fiber's current span, or parentless. With the layer, the span continues that trace: it is a root + * span whose `parent_span_id` is the external span. No dynamic sampling context is frozen, so the SDK + * builds one from the client the way it does for a head-of-trace span. + * - Without a parent, the span nests under a foreign active Sentry span (an `http.server` span from the + * Node SDK, a pageload in the browser) but never under a span this tracer created: Effect's own parent + * tracking is authoritative for those, so an active one is an enclosing `root: true` span or a span + * leaked from another fiber through the async context. Effect reports `root: true` for every + * parentless span, so the flag adds nothing and is not consulted. + * - A root span starts a new trace when `newTraceForRootSpans` is set, unless the user set up the + * current scope (`Sentry.continueTrace`, `Sentry.withScope`, an HTTP request's isolation scope). + */ +function startSentrySpan( + startInactiveSpan: StartInactiveSpan, + options: StartSpanOptions, + parent: Option.Option, + newTraceForRootSpans: boolean, +): Span { + if (Option.isSome(parent)) { + const parentSpan = parent.value; + + if (isSentrySpan(parentSpan)) { + return startInactiveSpan({ ...options, parentSpan: parentSpan.sentrySpan }); + } + + if (currentFiber !== undefined && continuesExternalSpans(currentFiber)) { + return withScope(scope => { + scope.setPropagationContext({ + traceId: parentSpan.traceId, + parentSpanId: parentSpan.spanId, + sampled: parentSpan.sampled, + sampleRand: _INTERNAL_safeMathRandom(), + }); + return withActiveSpan(null, () => startInactiveSpan(options)); + }); + } + + const enclosingSpan = currentFiber && getFiberSpan(currentFiber); + if (enclosingSpan !== undefined && isSentrySpan(enclosingSpan)) { + return startInactiveSpan({ ...options, parentSpan: enclosingSpan.sentrySpan }); + } + } + + const activeSpan = getActiveSpan(); + if (activeSpan && !isEffectSpan(activeSpan)) { + return startInactiveSpan({ ...options, parentSpan: activeSpan }); + } + + // A scope the user forked (`continueTrace`, `withScope`, an isolation scope) carries its own trace id. + // The scopes this tracer forks in `context()` clone the propagation context they were forked from, so + // even when one leaks into another fiber through the async context it still carries the process-wide + // trace id of the default scope. + const isProcessTrace = + getCurrentScope().getPropagationContext().traceId === getDefaultCurrentScope().getPropagationContext().traceId; + if (newTraceForRootSpans && isProcessTrace) { + return startNewTrace(() => startInactiveSpan(options)); + } + + return withActiveSpan(null, () => startInactiveSpan(options)); +} + +function createSentrySpan( + startInactiveSpan: StartInactiveSpan, + newTraceForRootSpans: boolean, + name: string, + parent: Option.Option, + context: Context.Context, + links: ReadonlyArray, + startTime: bigint, + kind: EffectTracer.SpanKind, +): SentrySpanLike { + const op = deriveOp(name); + const origin = deriveOrigin(name); + + const newSpan = startSentrySpan( + startInactiveSpan, + { + name, + startTime: nanosToHrTime(startTime), + // Setting these to `undefined` would strip the core defaults instead of leaving them in place. + attributes: { + ...(op && { [SENTRY_OP]: op }), + ...(origin && { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin }), + }, + }, + parent, + newTraceForRootSpans, + ); + markEffectSpan(newSpan); + + return new SentrySpanWrapper(name, parent, context, links, startTime, kind, newSpan); +} + +const makeSentryTracerV3 = ( + startInactiveSpan: StartInactiveSpan, + newTraceForRootSpans: boolean, +): EffectTracer.Tracer => { // Effect v3 API: span(name, parent, context, links, startTime, kind) return EffectTracer.make({ span( @@ -237,25 +415,25 @@ const makeSentryTracerV3 = (startInactiveSpan: StartInactiveSpan): EffectTracer. startTime: bigint, kind: EffectTracer.SpanKind, ) { - return createSentrySpan(startInactiveSpan, name, parent, context, links, startTime, kind); + return createSentrySpan(startInactiveSpan, newTraceForRootSpans, name, parent, context, links, startTime, kind); }, - context(execution: () => unknown, fiber: { currentSpan?: EffectTracer.AnySpan }) { - const currentSpan = fiber.currentSpan; - if (currentSpan === undefined || !isSentrySpan(currentSpan)) { - return execution(); - } - return withActiveSpan(currentSpan.sentrySpan, execution); + context(execution: () => unknown, fiber: FiberLike) { + return withFiberContext(fiber, execution); }, } as unknown as EffectTracer.Tracer); }; -const makeSentryTracerV4 = (startInactiveSpan: StartInactiveSpan): EffectTracer.Tracer => { +const makeSentryTracerV4 = ( + startInactiveSpan: StartInactiveSpan, + newTraceForRootSpans: boolean, +): EffectTracer.Tracer => { const EFFECT_EVALUATE = '~effect/Effect/evaluate' as const; return EffectTracer.make({ span(options) { return createSentrySpan( startInactiveSpan, + newTraceForRootSpans, options.name, options.parent, options.annotations, @@ -265,11 +443,7 @@ const makeSentryTracerV4 = (startInactiveSpan: StartInactiveSpan): EffectTracer. ); }, context(primitive, fiber) { - const currentSpan = fiber.currentSpan; - if (currentSpan === undefined || !isSentrySpan(currentSpan)) { - return primitive[EFFECT_EVALUATE](fiber); - } - return withActiveSpan(currentSpan.sentrySpan, () => primitive[EFFECT_EVALUATE](fiber)); + return withFiberContext(fiber, () => primitive[EFFECT_EVALUATE](fiber)); }, }); }; @@ -279,7 +453,17 @@ const makeSentryTracerV4 = (startInactiveSpan: StartInactiveSpan): EffectTracer. * * Use the `SentryEffectTracer` exported from `@sentry/effect` rather than calling this directly — the * client and server entries each bind the right `startInactiveSpan` for their platform. + * + * `newTraceForRootSpans` is the one behavioural difference between the platforms: Effect gives every + * parentless span a fresh trace id, and on a long-lived server nothing else forks the propagation + * context, so the server tracer follows Effect and starts a new trace. In the browser the page trace is + * the intended home of every span, so the client tracer keeps parentless spans in it. */ -export function makeSentryTracer(startInactiveSpan: StartInactiveSpan): EffectTracer.Tracer { - return isEffectV4 ? makeSentryTracerV4(startInactiveSpan) : makeSentryTracerV3(startInactiveSpan); +export function makeSentryTracer( + startInactiveSpan: StartInactiveSpan, + newTraceForRootSpans: boolean, +): EffectTracer.Tracer { + return isEffectV4 + ? makeSentryTracerV4(startInactiveSpan, newTraceForRootSpans) + : makeSentryTracerV3(startInactiveSpan, newTraceForRootSpans); } diff --git a/packages/effect/test/tracer.test.ts b/packages/effect/test/tracer.test.ts index 90429b3a8bcd..a7aace022282 100644 --- a/packages/effect/test/tracer.test.ts +++ b/packages/effect/test/tracer.test.ts @@ -2,10 +2,13 @@ import { describe, expect, it } from '@effect/vitest'; import * as sentryCore from '@sentry/core'; import * as sentryCoreBrowser from '@sentry/core/browser'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { ServerRuntimeClient } from '@sentry/core/server'; import { Effect } from 'effect'; -import { afterEach, vi } from 'vitest'; +import * as Tracer from 'effect/Tracer'; +import { afterEach, beforeEach, vi } from 'vitest'; import { SentryEffectTracer as clientTracer } from '../src/client/tracer'; import { SentryEffectTracer as serverTracer } from '../src/server/tracer'; +import { SentryEffectExternalSpanLayer } from '../src/tracer'; // The two variants differ only in which module they start spans through, so spying on `spanApi` also // asserts that wiring: the client tracer must go through `@sentry/core/browser` (which installs @@ -31,7 +34,7 @@ function mockSpan(overrides: Record = {}): sentryCore.Span { } as unknown as sentryCore.Span; } -describe.each(VARIANTS)('SentryEffectTracer ($variant)', ({ tracer, spanApi }) => { +describe.each(VARIANTS)('SentryEffectTracer ($variant)', ({ variant, tracer, spanApi }) => { const withSentryTracer = (effect: Effect.Effect) => Effect.withTracer(effect, tracer); afterEach(() => { @@ -229,4 +232,158 @@ describe.each(VARIANTS)('SentryEffectTracer ($variant)', ({ tracer, spanApi }) = expect(result).toBe('with-tracer'); }).pipe(Effect.withTracer(tracer)), ); + + describe('trace structure', () => { + const traceId = 'a'.repeat(32); + const spanId = 'b'.repeat(16); + + beforeEach(() => { + const client = new ServerRuntimeClient({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [], + transport: () => sentryCore.createTransport({ recordDroppedEvent: () => undefined }, () => Promise.resolve({})), + stackParser: () => [], + tracesSampleRate: 1, + traceLifecycle: 'static', + }); + sentryCore.getCurrentScope().setClient(client); + client.init(); + }); + + afterEach(() => { + sentryCore.getCurrentScope().setClient(undefined); + }); + + const currentSentrySpan = Effect.map( + Effect.currentSpan, + span => (span as unknown as { sentrySpan: sentryCore.Span }).sentrySpan, + ); + + const run = (effect: Effect.Effect): A => Effect.runSync(Effect.withTracer(effect, tracer)); + + it('treats an external parent as no parent without the external span layer', () => { + const parent = Tracer.externalSpan({ traceId, spanId, sampled: true }); + const span = run(Effect.withSpan('reactor', { parent })(currentSentrySpan)); + + expect(sentryCore.spanToJSON(span).trace_id).not.toBe(traceId); + expect(sentryCore.spanToJSON(span).parent_span_id).toBeUndefined(); + }); + + it('nests a span with an ignored external parent under the enclosing Effect span', () => { + const parent = Tracer.externalSpan({ traceId, spanId, sampled: true }); + const [outer, inner] = run( + Effect.withSpan('outer')( + Effect.all([currentSentrySpan, Effect.withSpan('inner', { parent })(currentSentrySpan)]), + ), + ); + + expect(sentryCore.spanToJSON(inner)).toMatchObject({ + trace_id: sentryCore.spanToJSON(outer).trace_id, + parent_span_id: outer.spanContext().spanId, + }); + }); + + it('continues the trace of an external parent as a new root span with the external span layer', () => { + const parent = Tracer.externalSpan({ traceId, spanId, sampled: true }); + const span = run( + Effect.withSpan('reactor', { parent })(currentSentrySpan).pipe(Effect.provide(SentryEffectExternalSpanLayer)), + ); + + expect(sentryCore.spanToJSON(span)).toMatchObject({ trace_id: traceId, parent_span_id: spanId }); + expect(sentryCore.spanIsSampled(span)).toBe(true); + expect(sentryCore.getDynamicSamplingContextFromSpan(span)).toMatchObject({ + trace_id: traceId, + public_key: 'public', + sampled: 'true', + }); + }); + + it('honors the sampling decision of an external parent', () => { + const parent = Tracer.externalSpan({ traceId, spanId, sampled: false }); + const span = run( + Effect.withSpan('reactor', { parent })(currentSentrySpan).pipe(Effect.provide(SentryEffectExternalSpanLayer)), + ); + + expect(sentryCore.spanToJSON(span).trace_id).toBe(traceId); + expect(sentryCore.spanIsSampled(span)).toBe(false); + }); + + it('does not nest a root: true span under the enclosing Effect span', () => { + const [outer, inner] = run( + Effect.withSpan('outer')( + Effect.all([currentSentrySpan, Effect.withSpan('inner', { root: true })(currentSentrySpan)]), + ), + ); + + expect(sentryCore.spanToJSON(inner).parent_span_id).toBeUndefined(); + if (variant === 'server') { + expect(sentryCore.spanToJSON(inner).trace_id).not.toBe(sentryCore.spanToJSON(outer).trace_id); + } else { + expect(sentryCore.spanToJSON(inner).trace_id).toBe(sentryCore.spanToJSON(outer).trace_id); + } + }); + + it('nests a parentless Effect span under a foreign active Sentry span', () => { + sentryCore.startSpan({ name: 'http.server' }, request => { + const span = run(Effect.withSpan('handler')(currentSentrySpan)); + + expect(sentryCore.spanToJSON(span).parent_span_id).toBe(request.spanContext().spanId); + expect(sentryCore.spanToJSON(span).trace_id).toBe(request.spanContext().traceId); + }); + }); + + it('does not parent a parentless Effect span on a span leaked from another fiber', () => { + const leaked = run(Effect.withSpan('other-fiber')(currentSentrySpan)); + + sentryCore.withActiveSpan(leaked, () => { + const span = run(Effect.withSpan('root')(currentSentrySpan)); + + expect(sentryCore.spanToJSON(span).parent_span_id).toBeUndefined(); + }); + }); + + it('does not parent a parentless Effect span on an unsampled span leaked from another fiber', () => { + const leaked = sentryCore.withScope(scope => { + scope.setPropagationContext({ traceId, sampled: false, sampleRand: 0.5 }); + return run(Effect.withSpan('unsampled-fiber')(currentSentrySpan)); + }); + expect(sentryCore.spanIsSampled(leaked)).toBe(false); + + sentryCore.withActiveSpan(leaked, () => { + const span = run(Effect.withSpan('root')(currentSentrySpan)); + + expect(sentryCore.spanIsSampled(span)).toBe(true); + expect(sentryCore.spanToJSON(span).parent_span_id).toBeUndefined(); + }); + }); + + it('keeps a parentless Effect span in a trace the user continued', () => { + sentryCore.continueTrace({ sentryTrace: `${traceId}-${spanId}-1`, baggage: undefined }, () => { + const span = run(Effect.withSpan('handler')(currentSentrySpan)); + + expect(sentryCore.spanToJSON(span)).toMatchObject({ trace_id: traceId, parent_span_id: spanId }); + }); + }); + + if (variant === 'server') { + it('starts a new trace for every parentless Effect span', () => { + const processTraceId = sentryCore.getCurrentScope().getPropagationContext().traceId; + const first = run(Effect.withSpan('first')(currentSentrySpan)); + const second = run(Effect.withSpan('second')(currentSentrySpan)); + + expect(sentryCore.spanToJSON(first).trace_id).not.toBe(processTraceId); + expect(sentryCore.spanToJSON(second).trace_id).not.toBe(processTraceId); + expect(sentryCore.spanToJSON(first).trace_id).not.toBe(sentryCore.spanToJSON(second).trace_id); + }); + } else { + it('keeps parentless Effect spans in the page trace', () => { + const pageTraceId = sentryCore.getCurrentScope().getPropagationContext().traceId; + const first = run(Effect.withSpan('first')(currentSentrySpan)); + const second = run(Effect.withSpan('second')(currentSentrySpan)); + + expect(sentryCore.spanToJSON(first).trace_id).toBe(pageTraceId); + expect(sentryCore.spanToJSON(second).trace_id).toBe(pageTraceId); + }); + } + }); }); diff --git a/packages/elysia/package.json b/packages/elysia/package.json index c230bf482f06..7241be71f888 100644 --- a/packages/elysia/package.json +++ b/packages/elysia/package.json @@ -34,7 +34,7 @@ "dependencies": { "@sentry/bun": "11.2.0", "@sentry/core": "11.2.0", - "@sentry/conventions": "^0.24.0" + "@sentry/conventions": "^0.25.0" }, "peerDependencies": { "elysia": "^1.4.0" diff --git a/packages/ember/package.json b/packages/ember/package.json index 3a916153229e..60a75afb08dc 100644 --- a/packages/ember/package.json +++ b/packages/ember/package.json @@ -45,7 +45,7 @@ "@embroider/addon-shim": "^1.10.2", "@embroider/macros": "^1.20.2", "@sentry/browser": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "decorator-transforms": "^2.3.2" }, diff --git a/packages/google-cloud-serverless/package.json b/packages/google-cloud-serverless/package.json index 1af87d31b945..02cfe5320b59 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.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "@sentry/node": "11.2.0", "@sentry/server-runtime-injection": "11.2.0", diff --git a/packages/hono/package.json b/packages/hono/package.json index 9599e0735040..2b31930606a0 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -74,7 +74,7 @@ "dependencies": { "@opentelemetry/api": "^1.9.1", "@sentry/core": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/server-utils": "11.2.0" }, "peerDependencies": { diff --git a/packages/hono/src/bun/sdk.ts b/packages/hono/src/bun/sdk.ts index c62dae003f27..26f9c987348b 100644 --- a/packages/hono/src/bun/sdk.ts +++ b/packages/hono/src/bun/sdk.ts @@ -13,13 +13,16 @@ import { LOW_QUALITY_TRANSACTION_PATTERNS } from '../shared/lowQualityTransactio * When manually calling `init`, add the `honoIntegration` to the `integrations` array to set up the Hono integration. */ export function init(options: HonoBunOptions): Client | undefined { - if (getClient()) { + const existingClient = getClient(); + if (existingClient) { consoleSandbox(() => { // eslint-disable-next-line no-console console.warn( '[Sentry] Sentry is already initialized. Sentry should only be initialized once, through the `sentry()` middleware. Remove the `Sentry.init()` call, if one exists.', ); }); + // Re-initializing would replace the client and drop anything buffered on it + return existingClient; } applySdkMetadata(options, 'hono', ['hono', 'bun']); diff --git a/packages/hono/src/deno/sdk.ts b/packages/hono/src/deno/sdk.ts index 43083981a3c3..1c40bdb3bdbf 100644 --- a/packages/hono/src/deno/sdk.ts +++ b/packages/hono/src/deno/sdk.ts @@ -13,13 +13,16 @@ import { LOW_QUALITY_TRANSACTION_PATTERNS } from '../shared/lowQualityTransactio * When manually calling `init`, add the `honoIntegration` to the `integrations` array to set up the Hono integration. */ export function init(options: HonoDenoOptions): Client | undefined { - if (getClient()) { + const existingClient = getClient(); + if (existingClient) { consoleSandbox(() => { // eslint-disable-next-line no-console console.warn( '[Sentry] Sentry is already initialized. Sentry should only be initialized once, through the `sentry()` middleware. Remove the `Sentry.init()` call, if one exists.', ); }); + // Re-initializing would replace the client and drop anything buffered on it + return existingClient; } applySdkMetadata(options, 'hono', ['hono', 'deno']); diff --git a/packages/hono/test/bun/middleware.test.ts b/packages/hono/test/bun/middleware.test.ts index 50f70a77883e..f2ef49fa19a8 100644 --- a/packages/hono/test/bun/middleware.test.ts +++ b/packages/hono/test/bun/middleware.test.ts @@ -171,14 +171,22 @@ describe('Hono Bun Middleware', () => { }); describe('double-init guard', () => { - it('still calls init even when Sentry is already initialized', () => { + it('does not re-initialize when Sentry is already initialized', () => { const fakeClient = { getOptions: () => ({}) }; getClientMock.mockReturnValue(fakeClient as unknown as SentryCore.Client); const app = new Hono(); sentry(app, { dsn: 'https://public@dsn.ingest.sentry.io/1337' }); - expect(initBunMock).toHaveBeenCalledTimes(1); + expect(initBunMock).not.toHaveBeenCalled(); + }); + + it('returns the existing client when Sentry is already initialized', () => { + const fakeClient = { getOptions: () => ({}) }; + getClientMock.mockReturnValue(fakeClient as unknown as SentryCore.Client); + + expect(init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' })).toBe(fakeClient); + expect(initBunMock).not.toHaveBeenCalled(); }); it('emits a console.warn directing to remove the duplicate init call when Sentry is already initialized', () => { diff --git a/packages/hono/test/deno/middleware.test.ts b/packages/hono/test/deno/middleware.test.ts index a6caa2d44455..84506ab494fa 100644 --- a/packages/hono/test/deno/middleware.test.ts +++ b/packages/hono/test/deno/middleware.test.ts @@ -171,14 +171,22 @@ describe('Hono Deno Middleware', () => { }); describe('double-init guard', () => { - it('still calls init even when Sentry is already initialized', () => { + it('does not re-initialize when Sentry is already initialized', () => { const fakeClient = { getOptions: () => ({}) }; getClientMock.mockReturnValue(fakeClient as unknown as SentryCore.Client); const app = new Hono(); sentry(app, { dsn: 'https://public@dsn.ingest.sentry.io/1337' }); - expect(initDenoMock).toHaveBeenCalledTimes(1); + expect(initDenoMock).not.toHaveBeenCalled(); + }); + + it('returns the existing client when Sentry is already initialized', () => { + const fakeClient = { getOptions: () => ({}) }; + getClientMock.mockReturnValue(fakeClient as unknown as SentryCore.Client); + + expect(init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' })).toBe(fakeClient); + expect(initDenoMock).not.toHaveBeenCalled(); }); it('emits a console.warn directing to remove the duplicate init call when Sentry is already initialized', () => { diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index 07baaee4f367..c12ea6bde10c 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -44,7 +44,7 @@ "access": "public" }, "dependencies": { - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "@sentry/node": "11.2.0", "@sentry/server-utils": "11.2.0" diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index fb210e6dfac8..2b2177b39a33 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -76,7 +76,7 @@ "@rollup/plugin-commonjs": "28.0.1", "@sentry/browser-utils": "11.2.0", "@sentry/bundler-plugins": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "@sentry/node": "11.2.0", "@sentry/opentelemetry": "11.2.0", diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index 828a4f0a541a..37763c0e0607 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -12,6 +12,7 @@ import { isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; import { browserTracingIntegration } from './browserTracingIntegration'; import { nextjsClientStackFrameNormalizationIntegration } from './clientNormalizationIntegration'; import { removeIsrSsgTraceMetaTags } from './routing/isrRoutingTracing'; +import { createNextRouteProvider } from './routing/routeProvider'; import { applyTunnelRouteOption } from './tunnelRoute'; export * from '@sentry/react'; @@ -65,6 +66,9 @@ export function init(options: BrowserOptions): Client | undefined { environment: options.environment || process.env.SENTRY_ENVIRONMENT || getClientVercelEnv() || process.env.NODE_ENV, defaultIntegrations: getDefaultIntegrations(options), release: process.env._sentryRelease || globalWithInjectedValues._sentryRelease, + // Both route manifests are injected at build time, so route parameterization works from `init` on, + // including for the pageload span and with tracing disabled. + routeProvider: createNextRouteProvider(), ...options, } satisfies BrowserOptions; diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts index 07258ff72416..1a7bb8a113de 100644 --- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts @@ -13,8 +13,10 @@ import { startBrowserTracingPageLoadSpan, WINDOW, getAbsoluteUrl, + resolveCurrentRoute, + resolveRoute, } from '@sentry/react'; -import { maybeParameterizeRoute } from './parameterization'; +import { stripTrailingSlash } from './parameterization'; import { SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE, @@ -24,14 +26,6 @@ import { } from '@sentry/conventions/attributes'; import { NAVIGATION, PAGELOAD } from '@sentry/conventions/op'; -/** - * Strips trailing slash from a pathname, unless it's the root path. - * This normalizes paths like '/about/' to '/about' to handle Next.js `trailingSlash: true` config. - */ -function stripTrailingSlash(pathname: string): string { - return pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; -} - function setNavigationSpanUrlAttributes(span: Span, urlPath: string, urlOrPath: string): void { span.setAttributes({ [URL_PATH]: urlPath, @@ -103,7 +97,7 @@ const currentRouterPatchingNavigationSpanRef: NavigationSpanRef = { current: und /** Instruments the Next.js app router for pageloads. */ export function appRouterInstrumentPageLoad(client: Client): void { const pathname = stripTrailingSlash(WINDOW.location.pathname); - const parameterizedPathname = maybeParameterizeRoute(pathname); + const parameterizedPathname = resolveCurrentRoute(client); startBrowserTracingPageLoadSpan(client, { // 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), @@ -158,7 +152,7 @@ export function appRouterInstrumentNavigation(client: Client): void { const basePath = process.env._sentryBasePath ?? globalWithInjectedBasePath._sentryBasePath; const normalizedHref = basePath && href.startsWith('/') && !href.startsWith(basePath) ? `${basePath}${href}` : href; const unparameterizedPathname = stripTrailingSlash(new URL(normalizedHref, WINDOW.location.href).pathname); - const parameterizedPathname = maybeParameterizeRoute(unparameterizedPathname); + const parameterizedPathname = resolveRoute(normalizedHref, client); // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. const spanName = parameterizedPathname ?? @@ -198,7 +192,7 @@ export function appRouterInstrumentNavigation(client: Client): void { WINDOW.addEventListener('popstate', () => { const pathname = stripTrailingSlash(WINDOW.location.pathname); - const parameterizedPathname = maybeParameterizeRoute(pathname); + const parameterizedPathname = resolveCurrentRoute(client); // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. const spanName = parameterizedPathname ?? (hasSpanStreamingEnabled(client) ? NAVIGATION_SPAN_NAME_FALLBACK : pathname); @@ -308,7 +302,7 @@ function patchRouter(client: Client, router: NextRouter, currentNavigationSpanRe ? `${basePath}${href}` : href; const transactionName = stripTrailingSlash(transactionNameifyRouterArgument(normalizedHref)); - const parameterizedPathname = maybeParameterizeRoute(transactionName); + const parameterizedPathname = resolveRoute(transactionName, client); currentNavigationSpanRef.current = startBrowserTracingNavigationSpan( client, diff --git a/packages/nextjs/src/client/routing/pagesRouterNavigationInstrumentation.ts b/packages/nextjs/src/client/routing/pagesRouterNavigationInstrumentation.ts index b94eadc5c134..44d842b6193a 100644 --- a/packages/nextjs/src/client/routing/pagesRouterNavigationInstrumentation.ts +++ b/packages/nextjs/src/client/routing/pagesRouterNavigationInstrumentation.ts @@ -5,10 +5,11 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, stripUrlQueryAndFragment, } from '@sentry/core'; -import { getAbsoluteUrl, startBrowserTracingNavigationSpan, WINDOW } from '@sentry/react'; +import { getAbsoluteUrl, startBrowserTracingNavigationSpan } from '@sentry/react'; import RouterImport from 'next/router'; import { SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE, URL_TEMPLATE } from '@sentry/conventions/attributes'; import { NAVIGATION } from '@sentry/conventions/op'; +import { getNextRouteFromPathname } from './pagesRouterRoutingInstrumentation'; // next/router v10 is CJS // @@ -17,8 +18,6 @@ const Router: typeof RouterImport = RouterImport.events ? RouterImport : (RouterImport as unknown as { default: typeof RouterImport }).default; -const globalObject = WINDOW; - /** * Instruments the Next.js pages router for navigation. * Only supported for client side routing. Works for Next >= 10. @@ -59,58 +58,3 @@ export function pagesRouterInstrumentNavigation(client: Client): void { ); }); } - -function getNextRouteFromPathname(pathname: string): string | undefined { - const pageRoutes = globalObject.__BUILD_MANIFEST?.sortedPages; - - // Page route should in 99.999% of the cases be defined by now but just to be sure we make a check here - if (!pageRoutes) { - return; - } - - return pageRoutes.find(route => { - const routeRegExp = convertNextRouteToRegExp(route); - return pathname.match(routeRegExp); - }); -} - -/** - * Converts a Next.js style route to a regular expression that matches on pathnames (no query params or URL fragments). - * - * In general this involves replacing any instances of square brackets in a route with a wildcard: - * e.g. "/users/[id]/info" becomes /\/users\/([^/]+?)\/info/ - * - * Some additional edgecases need to be considered: - * - All routes have an optional slash at the end, meaning users can navigate to "/users/[id]/info" or - * "/users/[id]/info/" - both will be resolved to "/users/[id]/info". - * - Non-optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[...params]"). - * - Optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[[...params]]"). - * - * @param route A Next.js style route as it is found in `global.__BUILD_MANIFEST.sortedPages` - */ -function convertNextRouteToRegExp(route: string): RegExp { - // We can assume a route is at least "/". - const routeParts = route.split('/'); - - let optionalCatchallWildcardRegex = ''; - if (routeParts[routeParts.length - 1]?.match(/^\[\[\.\.\..+\]\]$/)) { - // If last route part has pattern "[[...xyz]]" we pop the latest route part to get rid of the required trailing - // slash that would come before it if we didn't pop it. - routeParts.pop(); - optionalCatchallWildcardRegex = '(?:/(.+?))?'; - } - - const rejoinedRouteParts = routeParts - .map( - routePart => - routePart - .replace(/^\[\.\.\..+\]$/, '(.+?)') // Replace catch all wildcard with regex wildcard - .replace(/^\[.*\]$/, '([^/]+?)'), // Replace route wildcards with lazy regex wildcards - ) - .join('/'); - - // oxlint-disable-next-line sdk/no-regexp-constructor -- routeParts are from the build manifest, so no raw user input - return new RegExp( - `^${rejoinedRouteParts}${optionalCatchallWildcardRegex}(?:/)?$`, // optional slash at the end - ); -} diff --git a/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts index 8cbf116334e4..7a4ebaad3877 100644 --- a/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts @@ -122,3 +122,63 @@ export function pagesRouterInstrumentPageLoad(client: Client): void { { sentryTrace, baggage }, ); } + +/** + * Matches a pathname against the Pages Router build manifest, e.g. `/users/1` -> `/users/[id]`. + * + * Expects a pathname without `basePath`, which is what Next reports internally. + */ +export function getNextRouteFromPathname(pathname: string): string | undefined { + const pageRoutes = globalObject.__BUILD_MANIFEST?.sortedPages; + + // Page route should in 99.999% of the cases be defined by now but just to be sure we make a check here + if (!pageRoutes) { + return; + } + + return pageRoutes.find(route => { + const routeRegExp = convertNextRouteToRegExp(route); + return pathname.match(routeRegExp); + }); +} + +/** + * Converts a Next.js style route to a regular expression that matches on pathnames (no query params or URL fragments). + * + * In general this involves replacing any instances of square brackets in a route with a wildcard: + * e.g. "/users/[id]/info" becomes /\/users\/([^/]+?)\/info/ + * + * Some additional edgecases need to be considered: + * - All routes have an optional slash at the end, meaning users can navigate to "/users/[id]/info" or + * "/users/[id]/info/" - both will be resolved to "/users/[id]/info". + * - Non-optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[...params]"). + * - Optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[[...params]]"). + * + * @param route A Next.js style route as it is found in `global.__BUILD_MANIFEST.sortedPages` + */ +function convertNextRouteToRegExp(route: string): RegExp { + // We can assume a route is at least "/". + const routeParts = route.split('/'); + + let optionalCatchallWildcardRegex = ''; + if (routeParts[routeParts.length - 1]?.match(/^\[\[\.\.\..+\]\]$/)) { + // If last route part has pattern "[[...xyz]]" we pop the latest route part to get rid of the required trailing + // slash that would come before it if we didn't pop it. + routeParts.pop(); + optionalCatchallWildcardRegex = '(?:/(.+?))?'; + } + + const rejoinedRouteParts = routeParts + .map( + routePart => + routePart + .replace(/^\[\.\.\..+\]$/, '(.+?)') // Replace catch all wildcard with regex wildcard + .replace(/^\[.*\]$/, '([^/]+?)'), // Replace route wildcards with lazy regex wildcards + ) + .join('/'); + + // oxlint-disable-next-line sdk/no-regexp-constructor -- routeParts are from the build manifest, so no raw user input + return new RegExp( + `^${rejoinedRouteParts}${optionalCatchallWildcardRegex}(?:/)?$`, // optional slash at the end + ); +} diff --git a/packages/nextjs/src/client/routing/parameterization.ts b/packages/nextjs/src/client/routing/parameterization.ts index da25c1beb840..9b366d705637 100644 --- a/packages/nextjs/src/client/routing/parameterization.ts +++ b/packages/nextjs/src/client/routing/parameterization.ts @@ -12,6 +12,32 @@ let cachedManifestString: string | undefined = undefined; const compiledRegexCache: Map = new Map(); const routeResultCache: Map = new Map(); +const globalWithInjectedBasePath = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryBasePath: string | undefined; +}; + +/** + * Strips trailing slash from a pathname, unless it's the root path. + * This normalizes paths like '/about/' to '/about' to handle Next.js `trailingSlash: true` config. + */ +export function stripTrailingSlash(pathname: string): string { + return pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; +} + +/** + * Removes the configured `basePath` from a pathname. + * + * App Router routes are generated with `basePath` baked in, but Next strips it internally for the + * Pages Router, so `__BUILD_MANIFEST.sortedPages` holds routes without it. + */ +export function stripBasePath(pathname: string): string { + const basePath = process.env._sentryBasePath ?? globalWithInjectedBasePath._sentryBasePath; + + return basePath && (pathname === basePath || pathname.startsWith(`${basePath}/`)) + ? pathname.slice(basePath.length) || '/' + : pathname; +} + // Specificity ranks for a single route segment, from most to least specific. `END` is the rank of // the position just past the last segment of a route, so that a route which stops is compared // against whatever the longer route continues with. diff --git a/packages/nextjs/src/client/routing/routeProvider.ts b/packages/nextjs/src/client/routing/routeProvider.ts new file mode 100644 index 000000000000..d6ef74b8698c --- /dev/null +++ b/packages/nextjs/src/client/routing/routeProvider.ts @@ -0,0 +1,26 @@ +import type { RouteProvider } from '@sentry/react'; +import { createUrlRouteProvider } from '@sentry/react'; +import { maybeParameterizeRoute, stripBasePath, stripTrailingSlash } from './parameterization'; +import { getNextRouteFromPathname } from './pagesRouterRoutingInstrumentation'; + +/** + * Resolves a URL against whichever router manifest the app ships. + * + * App Router routes are generated with `basePath` baked in, which is what `location.pathname` gives + * us; Next strips it internally for the Pages Router, so the fallback strips it too. + */ +function resolveNextRoute(url: { pathname: string }): string | undefined { + const pathname = stripTrailingSlash(url.pathname); + + return maybeParameterizeRoute(pathname) ?? getNextRouteFromPathname(stripBasePath(pathname)); +} + +/** + * A route provider backed by the route manifests Next.js injects at build time. + * + * Both manifests are on the global object before `Sentry.init` runs, so this needs no router and no + * tracing integration: registering it is what lets anything else in the SDK name a route. + */ +export function createNextRouteProvider(): RouteProvider { + return createUrlRouteProvider(resolveNextRoute); +} diff --git a/packages/nextjs/src/server/useCacheInstrumentation.ts b/packages/nextjs/src/server/useCacheInstrumentation.ts index 124e2c8b13ca..ac0a32373790 100644 --- a/packages/nextjs/src/server/useCacheInstrumentation.ts +++ b/packages/nextjs/src/server/useCacheInstrumentation.ts @@ -1,7 +1,16 @@ import { createHash } from 'node:crypto'; -import { CACHE_ITEM_AGE, CACHE_OPERATION, CACHE_TAGS, CACHE_TTL } from '@sentry/conventions/attributes'; +import { + CACHE_HIT, + CACHE_ITEM_AGE, + CACHE_KEY, + CACHE_OPERATION, + CACHE_TAGS, + CACHE_TTL, + SENTRY_LINK_TYPE, + SENTRY_ORIGIN, +} from '@sentry/conventions/attributes'; import { CACHE_GET, CACHE_PUT } from '@sentry/conventions/op'; -import type { Span } from '@sentry/core'; +import type { Span, SpanContextData } from '@sentry/core'; import { CACHE_OPERATION_NAMES, debug, @@ -11,9 +20,7 @@ import { getClient, hasSpanStreamingEnabled, hasSpansEnabled, - SEMANTIC_ATTRIBUTE_CACHE_HIT, - SEMANTIC_ATTRIBUTE_CACHE_KEY, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + LRUMap, spanIsSampled, startSpan, timestampInSeconds, @@ -27,9 +34,15 @@ const NEXT_CACHE_HANDLERS_MAP = Symbol.for('@next/cache-handlers-map'); const NEXT_PRIVATE_CACHE_HANDLER = Symbol.for('@next/cache-handlers-private'); const SENTRY_CACHE_INSTRUMENTED = Symbol.for('sentry.nextjs.cacheHandlersInstrumented'); const SENTRY_WRAPPED_HANDLERS = Symbol.for('sentry.nextjs.wrappedCacheHandlers'); +const SENTRY_CACHE_ORIGINS = Symbol.for('sentry.nextjs.cacheOrigins'); const INTEGRATION_NAME = 'NextjsUseCache'; const CACHE_SPAN_ORIGIN = 'auto.cache.nextjs'; +const CACHE_ORIGIN_LINK_TYPE = 'cache_origin'; + +// Hard memory bound for remembered fill origins: 2000 entries of a 12-char digest plus one span +// context stay well under 1 MB. +const CACHE_ORIGINS_MAX_SIZE = 2_000; // Next.js' `INFINITE_CACHE` sentinel. An `expire` at or above it means "never expires", which carries no signal as a TTL attribute. // https://github.com/vercel/next.js/blob/ed1aab5d386d07ee2f553107dd39995251a6e44e/packages/next/src/lib/constants.ts#L43-L46 @@ -62,8 +75,15 @@ type GlobalWithCacheHandlers = typeof globalThis & { [NEXT_PRIVATE_CACHE_HANDLER]?: UseCacheHandler; [SENTRY_CACHE_INSTRUMENTED]?: boolean; [SENTRY_WRAPPED_HANDLERS]?: WeakSet; + [SENTRY_CACHE_ORIGINS]?: LRUMap; }; +interface CacheOrigin { + context: SpanContextData; + /** The fill's `CacheEntry.timestamp`. Identifies the entry revision this origin wrote. */ + entryTimestamp: number; +} + /** * Cache keys are long serialized payloads (function id + arguments), so spans carry a digest * instead. This bounds span size, avoids leaking user data, and still groups identical keys. @@ -82,9 +102,8 @@ function shouldRecordCacheSpan(): boolean { return !!activeSpan && spanIsSampled(activeSpan); } -function startCacheSpan(op: typeof CACHE_GET | typeof CACHE_PUT, cacheKey: string, callback: (span: Span) => T): T { +function startCacheSpan(op: typeof CACHE_GET | typeof CACHE_PUT, digest: string, callback: (span: Span) => T): T { const client = getClient(); - const digest = keyDigest(cacheKey); return startSpan( { @@ -92,8 +111,8 @@ function startCacheSpan(op: typeof CACHE_GET | typeof CACHE_PUT, cacheKey: st name: client && hasSpanStreamingEnabled(client) ? op : digest, op, attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: CACHE_SPAN_ORIGIN, - [SEMANTIC_ATTRIBUTE_CACHE_KEY]: [digest], + [SENTRY_ORIGIN]: CACHE_SPAN_ORIGIN, + [CACHE_KEY]: [digest], [CACHE_OPERATION]: CACHE_OPERATION_NAMES[op], }, }, @@ -115,18 +134,19 @@ function isExpired(ageMs: number | undefined, expire: number | undefined): boole /** * A missing entry is a miss. Next.js' default handler also returns no entry for expired, evicted, - * or tag-invalidated entries, so those count as misses too. + * or tag-invalidated entries, so those count as misses too. Returns whether the read was a hit. */ -function setEntryAttributes(span: Span, entry: unknown): void { +function recordCacheEntry(span: Span, entry: unknown): boolean { if (entry === undefined) { - span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, false); - return; + span.setAttribute(CACHE_HIT, false); + return false; } const { timestamp, expire, tags } = (entry ?? {}) as UseCacheEntry; const ageMs = typeof timestamp === 'number' ? timestampInSeconds() * 1000 - timestamp : undefined; - span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, !isExpired(ageMs, expire)); + const hit = !isExpired(ageMs, expire); + span.setAttribute(CACHE_HIT, hit); if (ageMs !== undefined) { // Clamped: with a remote handler, the filling and the reading machine's clocks can drift. @@ -140,6 +160,56 @@ function setEntryAttributes(span: Span, entry: unknown): void { if (stringTags.length > 0) { span.setAttribute(CACHE_TAGS, stringTags); } + return hit; +} + +// Cache origins are scoped per-process (known limitation: no links across server instances — that would require storing metadata on `CacheEntry`). +// Hits on entries filled by another instance get no link. Lives on `globalThis` like the rest of the instrumentation state. +function getCacheOrigins(): LRUMap { + const globalWithCacheHandlers = globalThis as GlobalWithCacheHandlers; + if (!globalWithCacheHandlers[SENTRY_CACHE_ORIGINS]) { + globalWithCacheHandlers[SENTRY_CACHE_ORIGINS] = new LRUMap(CACHE_ORIGINS_MAX_SIZE); + } + return globalWithCacheHandlers[SENTRY_CACHE_ORIGINS]; +} + +/** + * Links a cache hit to the fill that wrote this entry revision (matched by timestamp). + * A hit with no matching origin (unsampled or dropped refill, another instance, process restart) + * gets no link instead of a stale one. + */ +function linkCacheOrigin(span: Span, originKey: string, entry: unknown): void { + const origin = getCacheOrigins().get(originKey); + const { timestamp } = (entry ?? {}) as UseCacheEntry; + if (origin && origin.entryTimestamp === timestamp) { + span.addLink({ + context: origin.context, + attributes: { [SENTRY_LINK_TYPE]: CACHE_ORIGIN_LINK_TYPE }, + }); + } +} + +/** + * Remembers the `cache.put` span as the origin of the entry revision it wrote. The handler drains + * `pendingEntry` before the write resolves, so the read here resolves immediately. + * An entry without a numeric `timestamp` has no revision identity and is not remembered. + */ +function rememberCacheOrigin(originKey: string, span: Span, pendingEntry: Promise): void { + Promise.resolve(pendingEntry).then( + entry => { + try { + const { timestamp } = (entry ?? {}) as UseCacheEntry; + if (typeof timestamp === 'number') { + getCacheOrigins().set(originKey, { context: span.spanContext(), entryTimestamp: timestamp }); + } + } catch (error) { + DEBUG_BUILD && debug.warn('Failed to remember a Next.js cache fill origin', error); + } + }, + () => { + // A rejected entry was never stored, so there is nothing to remember. + }, + ); } function isCacheHandler(value: unknown): value is UseCacheHandler { @@ -160,6 +230,10 @@ function getWrappedHandlers(): WeakSet { return globalWithCacheHandlers[SENTRY_WRAPPED_HANDLERS]; } +// One origin namespace per wrapped handler, so identical cache keys in different cache stores +// (default, remote, private) can never link across stores. +let wrappedHandlerCount = 0; + function instrumentHandler(handler: unknown): void { // Runs inside Next.js' handler registration, which must never fail because of Sentry. try { @@ -168,17 +242,21 @@ function instrumentHandler(handler: unknown): void { return; } wrappedHandlers.add(handler); + const originKeyPrefix = `${wrappedHandlerCount++}:`; fill(handler, 'get', (originalGet: UseCacheHandler['get']) => { return function (this: UseCacheHandler, cacheKey: string, softTags?: string[]): Promise { if (!shouldRecordCacheSpan()) { return originalGet.call(this, cacheKey, softTags); } - return startCacheSpan(CACHE_GET, cacheKey, span => + const digest = keyDigest(cacheKey); + return startCacheSpan(CACHE_GET, digest, span => // `Promise.resolve` because custom handlers may return the entry synchronously. Promise.resolve(originalGet.call(this, cacheKey, softTags)).then(entry => { try { - setEntryAttributes(span, entry); + if (recordCacheEntry(span, entry)) { + linkCacheOrigin(span, originKeyPrefix + digest, entry); + } } catch (error) { DEBUG_BUILD && debug.warn('Failed to read Next.js cache entry metadata', error); } @@ -193,9 +271,21 @@ function instrumentHandler(handler: unknown): void { if (!shouldRecordCacheSpan()) { return originalSet.call(this, cacheKey, pendingEntry); } + + const digest = keyDigest(cacheKey); // The handler drains `pendingEntry` (the still-streaming entry) before storing, so this // span covers producing and storing the entry, not just the write. - return startCacheSpan(CACHE_PUT, cacheKey, () => originalSet.call(this, cacheKey, pendingEntry)); + return startCacheSpan(CACHE_PUT, digest, span => + // Only a successful write becomes a fill origin: a failed write leaves no entry or the + // previous one (whose origin still stands). A dropped span (`ignoreSpans`) never + // reaches Sentry, so a link to it would be broken. + Promise.resolve(originalSet.call(this, cacheKey, pendingEntry)).then(result => { + if (span.isRecording()) { + rememberCacheOrigin(originKeyPrefix + digest, span, pendingEntry); + } + return result; + }), + ); }; }); } catch (error) { @@ -276,6 +366,7 @@ export function _instrumentUseCacheHandlers(): void { /** * Wraps Next.js' `use cache` handlers with `cache.get`/`cache.put` spans, so cached function * reads and fills show up in traces with hit/miss information. + * A hit also adds a `cache_origin` span link to the `cache.put` span of the trace that filled the entry. */ export const nextjsUseCacheIntegration = defineIntegration(() => { return { diff --git a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts index 37adaa05c25a..0f8a49816253 100644 --- a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts +++ b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts @@ -11,11 +11,13 @@ import '@sentry/core'; import '@sentry/react'; import '../../src/client/routing/appRouterRoutingInstrumentation'; import type * as AppRouterInstrumentation from '../../src/client/routing/appRouterRoutingInstrumentation'; +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 RouteProviderModule = typeof RouteProvider; interface NextRouter { back: () => void; @@ -69,6 +71,7 @@ async function setup(traceLifecycle: 'stream' | 'static'): Promise<{ const core: Core = await import('@sentry/core'); const react: React = await import('@sentry/react'); const instrumentation: Instrumentation = await import('../../src/client/routing/appRouterRoutingInstrumentation'); + const routeProvider: RouteProviderModule = await import('../../src/client/routing/routeProvider'); const client = new react.BrowserClient({ dsn: 'http://examplePublicKey@localhost/0', @@ -76,6 +79,7 @@ async function setup(traceLifecycle: 'stream' | 'static'): Promise<{ stackParser: () => [], tracesSampleRate: 1, traceLifecycle, + routeProvider: routeProvider.createNextRouteProvider(), integrations: [react.browserTracingIntegration({ instrumentPageLoad: false, instrumentNavigation: false })], }); core.setCurrentClient(client); diff --git a/packages/nextjs/test/client/parameterization.test.ts b/packages/nextjs/test/client/parameterization.test.ts index 88817c2cb518..50b7e426b0c6 100644 --- a/packages/nextjs/test/client/parameterization.test.ts +++ b/packages/nextjs/test/client/parameterization.test.ts @@ -1,10 +1,11 @@ import { GLOBAL_OBJ } from '@sentry/core'; import { afterEach, describe, expect, it } from 'vitest'; -import { maybeParameterizeRoute } from '../../src/client/routing/parameterization'; +import { maybeParameterizeRoute, stripBasePath } from '../../src/client/routing/parameterization'; import type { RouteManifest } from '../../src/config/manifest/types'; const globalWithInjectedManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRouteManifest: string | undefined; + _sentryBasePath?: string; }; describe('maybeParameterizeRoute', () => { @@ -1112,3 +1113,26 @@ describe('maybeParameterizeRoute', () => { }); }); }); + +describe('stripBasePath', () => { + afterEach(() => { + delete globalWithInjectedManifest._sentryBasePath; + }); + + it('strips the base path from paths under it', () => { + globalWithInjectedManifest._sentryBasePath = '/docs'; + + expect(stripBasePath('/docs/posts/hello')).toBe('/posts/hello'); + expect(stripBasePath('/docs')).toBe('/'); + }); + + it('leaves paths that only share a prefix with the base path alone', () => { + globalWithInjectedManifest._sentryBasePath = '/docs'; + + expect(stripBasePath('/documentation')).toBe('/documentation'); + }); + + it('leaves paths alone without a base path', () => { + expect(stripBasePath('/docs/posts/hello')).toBe('/docs/posts/hello'); + }); +}); diff --git a/packages/nextjs/test/client/routeProvider.test.ts b/packages/nextjs/test/client/routeProvider.test.ts new file mode 100644 index 000000000000..90721c503867 --- /dev/null +++ b/packages/nextjs/test/client/routeProvider.test.ts @@ -0,0 +1,97 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { BrowserClient, setCurrentClient, resolveCurrentRoute, resolveRoute, setRouteProvider } from '@sentry/react'; +import { createNextRouteProvider } from '../../src/client/routing/routeProvider'; + +const globalWithManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryRouteManifest?: string; + _sentryBasePath?: string; + __BUILD_MANIFEST?: { sortedPages?: string[] }; +}; + +let originalDocument: unknown; + +const MANIFEST = JSON.stringify({ + staticRoutes: [{ path: '/about' }], + dynamicRoutes: [{ path: '/users/:id', regex: '^/users/([^/]+)$', paramNames: ['id'] }], + isrRoutes: [], +}); + +function makeClient(): BrowserClient { + // Deliberately no integrations at all, so nothing tracing-related can be supplying the route. + const client = new BrowserClient({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [], + stackParser: () => [], + transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }), + }); + setCurrentClient(client); + client.init(); + + return client; +} + +describe('createNextRouteProvider', () => { + beforeEach(() => { + globalWithManifest._sentryRouteManifest = MANIFEST; + originalDocument = (GLOBAL_OBJ as { document?: unknown }).document; + // `getLocationHref()` reads `document.location.href`; the listener stubs are only here so + // `client.init()` does not trip over the stand-in. + (GLOBAL_OBJ as { document?: unknown }).document = { + location: { href: 'https://example.com/users/42' }, + addEventListener: () => {}, + removeEventListener: () => {}, + }; + }); + + afterEach(() => { + delete globalWithManifest._sentryRouteManifest; + delete globalWithManifest._sentryBasePath; + delete globalWithManifest.__BUILD_MANIFEST; + (GLOBAL_OBJ as { document?: unknown }).document = originalDocument; + }); + + it('parameterizes a URL from the build-time manifest', () => { + const client = makeClient(); + setRouteProvider(createNextRouteProvider(), client); + + expect(resolveRoute('https://example.com/users/42', client)).toBe('/users/:id'); + }); + + it('resolves the current route without a tracing integration', () => { + const client = makeClient(); + setRouteProvider(createNextRouteProvider(), client); + + expect(client.getIntegrationByName('BrowserTracing')).toBeUndefined(); + expect(resolveCurrentRoute(client)).toBe('/users/:id'); + }); + + it('returns undefined for a URL the manifest does not know', () => { + const client = makeClient(); + setRouteProvider(createNextRouteProvider(), client); + + expect(resolveRoute('https://example.com/nope/deep', client)).toBeUndefined(); + }); + + describe('Pages Router', () => { + beforeEach(() => { + globalWithManifest.__BUILD_MANIFEST = { sortedPages: ['/', '/_app', '/_error', '/posts/[slug]'] }; + }); + + it('falls back to the Pages Router manifest when the App Router manifest has no match', () => { + const client = makeClient(); + setRouteProvider(createNextRouteProvider(), client); + + expect(resolveRoute('https://example.com/posts/hello', client)).toBe('/posts/[slug]'); + }); + + it('strips `basePath` before matching, since Pages Router routes are generated without it', () => { + globalWithManifest._sentryBasePath = '/docs'; + const client = makeClient(); + setRouteProvider(createNextRouteProvider(), client); + + expect(resolveRoute('https://example.com/docs/posts/hello', client)).toBe('/posts/[slug]'); + expect(resolveRoute('https://example.com/docs', client)).toBe('/'); + }); + }); +}); diff --git a/packages/nextjs/test/clientSdk.test.ts b/packages/nextjs/test/clientSdk.test.ts index a4cf4869102f..f9367080f699 100644 --- a/packages/nextjs/test/clientSdk.test.ts +++ b/packages/nextjs/test/clientSdk.test.ts @@ -1,5 +1,5 @@ import type { Integration } from '@sentry/core'; -import { debug, getMainCarrier, SentryNonRecordingSpan } from '@sentry/core'; +import { debug, getMainCarrier, GLOBAL_OBJ, SentryNonRecordingSpan, spanToJSON } from '@sentry/core'; import * as SentryReact from '@sentry/react'; import { getClient, WINDOW } from '@sentry/react'; import { JSDOM } from 'jsdom'; @@ -188,6 +188,36 @@ describe('Client init()', () => { delete globalThis.__SENTRY_TRACING__; }); + it('names the pageload span after the parameterized route', () => { + const globalWithManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRouteManifest?: string }; + globalWithManifest._sentryRouteManifest = JSON.stringify({ + staticRoutes: [{ path: '/' }], + dynamicRoutes: [], + isrRoutes: [], + }); + + init({ dsn: TEST_DSN, tracesSampleRate: 1.0 }); + + expect(spanToJSON(SentryReact.getActiveSpan()!)).toMatchObject({ + name: '/', + attributes: { + 'sentry.op': 'pageload', + 'sentry.segment.name.source': 'route', + 'url.template': '/', + }, + }); + + delete globalWithManifest._sentryRouteManifest; + }); + + it('keeps a route provider passed by the user', () => { + const routeProvider = { resolveRoute: () => '/custom', resolveCurrentRoute: () => '/custom' }; + + init({ dsn: TEST_DSN, routeProvider }); + + expect(reactInit).toHaveBeenCalledWith(expect.objectContaining({ routeProvider })); + }); + it("doesn't run Next.js router instrumentation for bot user agents", () => { Object.defineProperty(WINDOW, 'navigator', { value: { diff --git a/packages/nextjs/test/server/useCacheInstrumentation.test.ts b/packages/nextjs/test/server/useCacheInstrumentation.test.ts index 29763cdaadb9..51037a67c005 100644 --- a/packages/nextjs/test/server/useCacheInstrumentation.test.ts +++ b/packages/nextjs/test/server/useCacheInstrumentation.test.ts @@ -3,9 +3,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => { const setAttribute = vi.fn(); + const addLink = vi.fn(); + const state = { spanCount: 0, recording: true }; return { setAttribute, - startSpan: vi.fn((_options: unknown, callback: (span: unknown) => unknown) => callback({ setAttribute })), + addLink, + state, + startSpan: vi.fn((_options: unknown, callback: (span: unknown) => unknown) => { + const n = ++state.spanCount; + const spanContext = { traceId: `trace-${n}`, spanId: `span-${n}`, traceFlags: 1 }; + const recording = state.recording; + return callback({ setAttribute, addLink, spanContext: () => spanContext, isRecording: () => recording }); + }), activeSpan: undefined as object | undefined, sampled: true, client: undefined as { getOptions: () => { traceLifecycle?: 'static' | 'stream' } } | undefined, @@ -26,6 +35,7 @@ const NEXT_CACHE_HANDLERS_MAP = Symbol.for('@next/cache-handlers-map'); const NEXT_PRIVATE_CACHE_HANDLER = Symbol.for('@next/cache-handlers-private'); const SENTRY_CACHE_INSTRUMENTED = Symbol.for('sentry.nextjs.cacheHandlersInstrumented'); const SENTRY_WRAPPED_HANDLERS = Symbol.for('sentry.nextjs.wrappedCacheHandlers'); +const SENTRY_CACHE_ORIGINS = Symbol.for('sentry.nextjs.cacheOrigins'); function createHandler(entry?: unknown) { return { @@ -55,6 +65,8 @@ describe('instrumentUseCacheHandlers', () => { mocks.activeSpan = {}; mocks.sampled = true; mocks.client = undefined; + mocks.state.spanCount = 0; + mocks.state.recording = true; }); afterEach(() => { @@ -67,6 +79,7 @@ describe('instrumentUseCacheHandlers', () => { NEXT_PRIVATE_CACHE_HANDLER, SENTRY_CACHE_INSTRUMENTED, SENTRY_WRAPPED_HANDLERS, + SENTRY_CACHE_ORIGINS, ]) { Reflect.deleteProperty(globalThis, symbol); } @@ -316,6 +329,161 @@ describe('instrumentUseCacheHandlers', () => { ); }); + describe('origin links', () => { + it('links a cache hit to the `cache.put` span of the fill', async () => { + const entry = { timestamp: nowMs() }; + const handler = installWithDefaultHandler(entry); + + await handler.set('cache-key', Promise.resolve(entry)); + await handler.get('cache-key'); + + expect(mocks.addLink).toHaveBeenCalledTimes(1); + expect(mocks.addLink).toHaveBeenCalledWith({ + context: { traceId: 'trace-1', spanId: 'span-1', traceFlags: 1 }, + attributes: { 'sentry.link.type': 'cache_origin' }, + }); + }); + + it('links to the most recent fill', async () => { + const entry = { timestamp: nowMs() }; + const handler = installWithDefaultHandler(entry); + + await handler.set('cache-key', Promise.resolve({ timestamp: entry.timestamp - 1_000 })); + await handler.set('cache-key', Promise.resolve(entry)); + await handler.get('cache-key'); + + expect(mocks.addLink).toHaveBeenCalledWith( + expect.objectContaining({ context: { traceId: 'trace-2', spanId: 'span-2', traceFlags: 1 } }), + ); + }); + + it('does not link a miss', async () => { + const handler = installWithDefaultHandler(undefined); + + await handler.set('cache-key', Promise.resolve({ timestamp: nowMs() })); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('does not link a hit whose fill is unknown', async () => { + const handler = installWithDefaultHandler({ timestamp: nowMs() }); + + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('does not link a hit to a fill of the same cache key in a different handler', async () => { + const entry = { timestamp: nowMs() }; + const defaultHandler = createHandler(entry); + const remoteHandler = createHandler(entry); + setGlobal( + NEXT_CACHE_HANDLERS_MAP, + new Map([ + ['default', defaultHandler], + ['remote', remoteHandler], + ]), + ); + _instrumentUseCacheHandlers(); + + await defaultHandler.set('cache-key', Promise.resolve(entry)); + await remoteHandler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + + await defaultHandler.get('cache-key'); + + expect(mocks.addLink).toHaveBeenCalledTimes(1); + }); + + it('does not link a hit that was filled under a different cache key', async () => { + const entry = { timestamp: nowMs() }; + const handler = installWithDefaultHandler(entry); + + await handler.set('other-key', Promise.resolve(entry)); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('does not link a hit on an entry refilled without a sampled parent span', async () => { + const refill = { timestamp: nowMs() }; + const handler = installWithDefaultHandler(refill); + + await handler.set('cache-key', Promise.resolve({ timestamp: refill.timestamp - 1_000 })); + + mocks.activeSpan = undefined; + await handler.set('cache-key', Promise.resolve(refill)); + mocks.activeSpan = {}; + + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('does not link a hit on an entry whose refill `cache.put` span is not recording', async () => { + const refill = { timestamp: nowMs() }; + const handler = installWithDefaultHandler(refill); + + await handler.set('cache-key', Promise.resolve({ timestamp: refill.timestamp - 1_000 })); + + // e.g. the `cache.put` op is filtered via `ignoreSpans` + mocks.state.recording = false; + await handler.set('cache-key', Promise.resolve(refill)); + mocks.state.recording = true; + + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('does not link a hit on an entry refilled by another server instance', async () => { + const entryFromOtherInstance = { timestamp: nowMs() }; + const handler = installWithDefaultHandler(entryFromOtherInstance); + + await handler.set('cache-key', Promise.resolve({ timestamp: entryFromOtherInstance.timestamp - 1_000 })); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('does not link hits on entries that carry no fill timestamp', async () => { + const entry = { expire: 3_600 }; + const handler = installWithDefaultHandler(entry); + + await handler.set('cache-key', Promise.resolve(entry)); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('does not remember fills whose entry rejected even though the write resolved', async () => { + // Custom handlers can swallow a failed entry and resolve the write anyway. + const handler = installWithDefaultHandler({ timestamp: nowMs() }); + + await handler.set('cache-key', Promise.reject(new Error('entry failed'))); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('does not remember fills whose write failed', async () => { + const entry = { timestamp: nowMs() }; + const handler = { + get: vi.fn(() => Promise.resolve(entry)), + set: vi.fn(() => Promise.reject(new Error('write failed'))), + }; + setGlobal(NEXT_CACHE_HANDLERS_MAP, new Map([['default', handler]])); + _instrumentUseCacheHandlers(); + + await expect(handler.set('cache-key', Promise.resolve(entry))).rejects.toThrow('write failed'); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + }); + it('creates a `cache.put` span around handler writes', async () => { const handler = installWithDefaultHandler(); diff --git a/packages/nitro/package.json b/packages/nitro/package.json index 8b5cb7d83e2c..530b17fd36c3 100644 --- a/packages/nitro/package.json +++ b/packages/nitro/package.json @@ -36,7 +36,7 @@ }, "dependencies": { "@sentry/bundler-plugins": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "@sentry/node": "11.2.0", "@sentry/server-runtime-injection": "11.2.0", diff --git a/packages/nitro/src/runtime/hooks/captureStorageEvents.ts b/packages/nitro/src/runtime/hooks/captureStorageEvents.ts index 066ec79bad45..de1b9399efdf 100644 --- a/packages/nitro/src/runtime/hooks/captureStorageEvents.ts +++ b/packages/nitro/src/runtime/hooks/captureStorageEvents.ts @@ -1,5 +1,5 @@ import * as dc from 'node:diagnostics_channel'; -import { CACHE_OPERATION, SENTRY_OP } from '@sentry/conventions/attributes'; +import { CACHE_HIT, CACHE_KEY, CACHE_OPERATION, SENTRY_OP } from '@sentry/conventions/attributes'; import { CACHE_GET, CACHE_PUT, CACHE_REMOVE } from '@sentry/conventions/op'; import { CACHE_OPERATION_NAMES, @@ -7,8 +7,6 @@ import { GLOBAL_OBJ, hasSpanStreamingEnabled, isObjectLike, - SEMANTIC_ATTRIBUTE_CACHE_HIT, - SEMANTIC_ATTRIBUTE_CACHE_KEY, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, } from '@sentry/core'; @@ -98,7 +96,7 @@ function setupStorageTracingChannel(operation: TracedOperation): void { [SENTRY_OP]: OPERATION_SPAN_OPS[operation], [CACHE_OPERATION]: cacheOperationName, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, - [SEMANTIC_ATTRIBUTE_CACHE_KEY]: cacheKeys.length > 1 ? cacheKeys : cacheKeys[0], + [CACHE_KEY]: cacheKeys.length > 1 ? cacheKeys : cacheKeys[0], 'db.operation.name': operation, 'db.collection.name': mountBase(data), 'db.system.name': data.driver?.name ?? 'unknown', @@ -112,7 +110,7 @@ function setupStorageTracingChannel(operation: TracedOperation): void { if (!('error' in data)) { const result = (data as { result?: unknown }).result; if (CACHE_HIT_OPERATIONS.has(operation)) { - span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, resolveCacheHit(operation, data.keys?.[0], result)); + span.setAttribute(CACHE_HIT, resolveCacheHit(operation, data.keys?.[0], result)); } } diff --git a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts index bcfad9b3679b..da1f517acc7c 100644 --- a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts +++ b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts @@ -131,12 +131,6 @@ function setupH3TracingChannels(): void { return span; }, { - captureError: () => ({ - mechanism: { - handled: false, - type: 'auto.http.nitro.onTraceError', - }, - }), beforeSpanEnd(span, data) { applyResponseStatus(span, data); diff --git a/packages/node/package.json b/packages/node/package.json index d449acfef618..8a7526570a31 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -78,7 +78,7 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "@sentry/opentelemetry": "11.2.0", "@sentry/server-runtime-injection": "11.2.0", diff --git a/packages/node/src/integrations/http/httpServerSpansIntegration.ts b/packages/node/src/integrations/http/httpServerSpansIntegration.ts index a00d048591a0..4712bdd61e7a 100644 --- a/packages/node/src/integrations/http/httpServerSpansIntegration.ts +++ b/packages/node/src/integrations/http/httpServerSpansIntegration.ts @@ -28,7 +28,11 @@ import { import { HTTP_SERVER } from '@sentry/conventions/op'; import type { Event, Integration, IntegrationFn, Span, SpanAttributes, SpanStatus } from '@sentry/core'; import type { HttpIncomingMessage, HttpServerResponse } from '@sentry/core/server'; -import { DEFAULT_IGNORE_STATUS_CODES, processHttpServerTransactionEvent } from '@sentry/core/server'; +import { + DEFAULT_IGNORE_STATUS_CODES, + getClientIPAddress, + processHttpServerTransactionEvent, +} from '@sentry/core/server'; import { debug, getSpanStatusFromHttpCode, @@ -320,14 +324,6 @@ function shouldIgnoreSpansForIncomingRequest( return false; } -/** - * First entry of `X-Forwarded-For`: the client as seen by the outermost proxy. - * https://opentelemetry.io/docs/specs/semconv/registry/attributes/client/#client-address - */ -function getForwardedClientAddress(forwardedFor: string | string[] | undefined): string | undefined { - return typeof forwardedFor === 'string' ? forwardedFor.split(',')[0]?.trim() || undefined : undefined; -} - function getIncomingRequestAttributesOnResponse( request: HttpIncomingMessage, response: HttpServerResponse, @@ -343,11 +339,12 @@ function getIncomingRequestAttributesOnResponse( 'http.response.status_text': statusMessage?.toUpperCase(), }; + // `client.address` is the originating client, so a forwarding header wins over the socket, which + // behind a proxy holds the proxy's address. The socket port is the proxy's too, so `client.port` + // stays unset then. `network.peer.*` below keeps the socket values. + const forwardedAddress = getClientIPAddress(request.headers); if (collectClientAddress) { - // `client.address` is the originating client, so a forwarding header wins over the socket, which - // behind a proxy holds the proxy's address. `network.peer.address` below keeps the socket value. - newAttributes[CLIENT_ADDRESS] = - getForwardedClientAddress(request.headers['x-forwarded-for']) ?? socket?.remoteAddress; + newAttributes[CLIENT_ADDRESS] = forwardedAddress || socket?.remoteAddress; } if (socket) { @@ -355,7 +352,7 @@ function getIncomingRequestAttributesOnResponse( newAttributes[SERVER_PORT] = localPort; newAttributes[NETWORK_LOCAL_ADDRESS] = localAddress; newAttributes[NETWORK_LOCAL_PORT] = localPort; - newAttributes[CLIENT_PORT] = remotePort; + newAttributes[CLIENT_PORT] = forwardedAddress ? undefined : remotePort; newAttributes[NETWORK_PEER_ADDRESS] = collectClientAddress ? remoteAddress : undefined; newAttributes[NETWORK_PEER_PORT] = remotePort; } diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index b8bb5eb1570f..14e100f637ec 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -56,7 +56,7 @@ "@nuxt/kit": "^4.5.2", "@sentry/browser": "11.2.0", "@sentry/cloudflare": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "@sentry/node": "11.2.0", "@sentry/bundler-plugins": "11.2.0", diff --git a/packages/nuxt/src/runtime/plugins/sentry.client.ts b/packages/nuxt/src/runtime/plugins/sentry.client.ts index 6d03b3cd9625..92c9478b7079 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.client.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.client.ts @@ -1,5 +1,11 @@ import { getClient, GLOBAL_OBJ } from '@sentry/core'; -import { browserTracingIntegration, vueIntegration } from '@sentry/vue'; +import { + browserTracingIntegration, + _INTERNAL_createVueRouteProvider, + getRouteProvider, + setRouteProvider, + vueIntegration, +} from '@sentry/vue'; import { defineNuxtPlugin, isNuxtError } from 'nuxt/app'; import type { GlobalObjWithIntegrationOptions } from '../../client/vueIntegration'; import { reportNuxtError } from '../utils'; @@ -28,6 +34,8 @@ interface VueRouter { beforeEach: (fn: (to: Route, from: Route, next?: () => void) => void) => void; } +type VueRouteProviderRouter = ReturnType[0]>; + // Tree-shakable guard to remove all code related to tracing declare const __SENTRY_TRACING__: boolean; @@ -35,6 +43,19 @@ export default defineNuxtPlugin({ name: 'sentry-client-integrations', dependsOn: ['sentry-client-config'], async setup(nuxtApp) { + // Registered outside the tracing guard, because route parameterization should not depend on + // tracing: anything that needs a route name (bfcache metrics, web vitals) can resolve one even + // when tracing is tree-shaken away. Nuxt installs the router before its plugins run, so unlike + // `@sentry/vue` this can read it straight off `nuxtApp`. + const client = getClient(); + // A `routeProvider` passed to `Sentry.init` is the user's choice, so it is left in place. + if (client && '$router' in nuxtApp && !getRouteProvider(client)) { + setRouteProvider( + _INTERNAL_createVueRouteProvider(() => nuxtApp.$router as VueRouteProviderRouter), + client, + ); + } + // This evaluates to true unless __SENTRY_TRACING__ is text-replaced with "false", in which case everything inside // will get tree-shaken away if (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) { diff --git a/packages/nuxt/src/runtime/utils/instrumentStorage.ts b/packages/nuxt/src/runtime/utils/instrumentStorage.ts index 621dc3024c1f..7d6cc98177fc 100644 --- a/packages/nuxt/src/runtime/utils/instrumentStorage.ts +++ b/packages/nuxt/src/runtime/utils/instrumentStorage.ts @@ -1,4 +1,4 @@ -import { CACHE_OPERATION, SENTRY_OP } from '@sentry/conventions/attributes'; +import { CACHE_HIT, CACHE_KEY, CACHE_OPERATION, SENTRY_OP } from '@sentry/conventions/attributes'; import { CACHE_GET, CACHE_PUT, CACHE_REMOVE } from '@sentry/conventions/op'; import { CACHE_OPERATION_NAMES, @@ -7,8 +7,6 @@ import { getClient, hasSpanStreamingEnabled, isObjectLike, - SEMANTIC_ATTRIBUTE_CACHE_HIT, - SEMANTIC_ATTRIBUTE_CACHE_KEY, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, SPAN_STATUS_OK, @@ -180,7 +178,7 @@ function createMethodWrapper( span.setStatus({ code: SPAN_STATUS_OK }); if (CACHE_HIT_METHODS.has(methodName)) { - span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, resolveCacheHit(methodName, args[0], result)); + span.setAttribute(CACHE_HIT, resolveCacheHit(methodName, args[0], result)); } return result; @@ -242,7 +240,7 @@ function createSpanStartOptions( [SENTRY_OP]: cacheOperation, [CACHE_OPERATION]: cacheOperationName, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', - [SEMANTIC_ATTRIBUTE_CACHE_KEY]: keys.length > 1 ? keys : keys[0], + [CACHE_KEY]: keys.length > 1 ? keys : keys[0], 'db.operation.name': methodName, 'db.collection.name': mountBase.replace(/:$/, ''), 'db.system.name': driver.name ?? 'unknown', diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json index 7a0462fc93b5..018bd403c64c 100644 --- a/packages/opentelemetry/package.json +++ b/packages/opentelemetry/package.json @@ -27,7 +27,7 @@ "access": "public" }, "dependencies": { - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0" }, "peerDependencies": { diff --git a/packages/profiling-node/src/integration.ts b/packages/profiling-node/src/integration.ts index 0766de3ba065..a158019ee036 100644 --- a/packages/profiling-node/src/integration.ts +++ b/packages/profiling-node/src/integration.ts @@ -275,18 +275,12 @@ class ContinuousProfiler { return; } - const transport = this._client.getTransport(); - if (!transport) { - DEBUG_BUILD && debug.log('[Profiling] No transport available to send profile chunk.'); - return; - } - const dsn = this._client.getDsn(); const metadata = this._client.getSdkMetadata(); const tunnel = this._client.getOptions().tunnel; const envelope = makeProfileChunkEnvelope('node', chunk, metadata?.sdk, tunnel, dsn); - transport.send(envelope).then(null, reason => { + this._client.sendEnvelope(envelope).then(null, reason => { DEBUG_BUILD && debug.error('Error while sending profile chunk envelope:', reason); }); } diff --git a/packages/profiling-node/test/integration.test.ts b/packages/profiling-node/test/integration.test.ts index 80a47ec05212..b7c24525419d 100644 --- a/packages/profiling-node/test/integration.test.ts +++ b/packages/profiling-node/test/integration.test.ts @@ -145,6 +145,32 @@ describe('ProfilingIntegration', () => { }), }); }); + + it('emits the `beforeEnvelope` hook for profile chunks', async () => { + const [client, transport] = makeSpanProfilingClient({ + profileLifecycle: 'manual', + profileSessionSampleRate: 1, + }); + + Sentry.setCurrentClient(client); + client.init(); + + vi.spyOn(transport, 'send').mockReturnValue(Promise.resolve({})); + + const beforeEnvelope = vi.fn(); + client.on('beforeEnvelope', beforeEnvelope); + + Sentry.profiler.startProfiler(); + await wait(1000); + Sentry.profiler.stopProfiler(); + + await Sentry.flush(1000); + + expect(beforeEnvelope).toHaveBeenCalledTimes(1); + expect(beforeEnvelope.mock.calls[0]?.[0]?.[1]?.[0]?.[0]).toMatchObject({ + type: 'profile_chunk', + }); + }); }); }); diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 92802dc6477c..4c3263e2b579 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -20,6 +20,11 @@ "./package.json": "./package.json", ".": { "types": "./build/types/index.types.d.ts", + "workerd": { + "import": "./build/esm/cloudflare/index.js", + "require": "./build/cjs/cloudflare/index.js", + "default": "./build/esm/cloudflare/index.js" + }, "browser": { "import": "./build/esm/index.client.js", "require": "./build/cjs/index.client.js" @@ -52,7 +57,7 @@ "dependencies": { "@opentelemetry/api": "^1.9.1", "@sentry/browser": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "@sentry/node": "11.2.0", "@sentry/react": "11.2.0", diff --git a/packages/react-router/src/cloudflare/index.ts b/packages/react-router/src/cloudflare/index.ts index e1e349306043..67db950b59fb 100644 --- a/packages/react-router/src/cloudflare/index.ts +++ b/packages/react-router/src/cloudflare/index.ts @@ -1,4 +1,5 @@ import { getTraceMetaTags } from '@sentry/core'; +import { isPrerenderRequest } from '../server/serverBuild'; export * from '../client'; @@ -11,6 +12,10 @@ export { wrapSentryHandleRequest } from '../server/wrapSentryHandleRequest'; * @returns A new ReadableStream with Sentry trace meta tags injected into the head section */ export function injectTraceMetaTags(body: ReadableStream): ReadableStream { + if (isPrerenderRequest()) { + return body; + } + const headClosingTag = ''; const reader = body.getReader(); diff --git a/packages/react-router/src/server/serverBuild.ts b/packages/react-router/src/server/serverBuild.ts index 3b46c745b4b7..1dd606f3ed1c 100644 --- a/packages/react-router/src/server/serverBuild.ts +++ b/packages/react-router/src/server/serverBuild.ts @@ -1,7 +1,7 @@ -import { GLOBAL_OBJ } from '@sentry/core'; +import { getIsolationScope, GLOBAL_OBJ, parseStringToURLObject } from '@sentry/core'; /** - * Subset of ServerBuild shape for middleware name lookup. + * Subset of ServerBuild shape for middleware name lookup and prerender detection. * The official React Router types don't expose `middleware` on route modules yet. * @internal */ @@ -14,6 +14,8 @@ interface ServerBuildLike { }; } >; + /** The paths React Router prerenders to static HTML at build time. */ + prerender?: string[]; } /** @internal */ @@ -53,6 +55,31 @@ export function getMiddlewareName(routeId: string, index: number): string | unde return middlewareFn?.name || undefined; } +function withoutTrailingSlash(path: string): string { + return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path; +} + +/** + * Whether the current request renders a path from the build's `prerender` list. + * + * In production these paths are served as static files and never reach the request handler, so a + * render of one is the build-time prerender. Trace meta tags must not be written into that HTML: + * every visitor of the static page would continue the same trace. + * + * @internal + */ +export function isPrerenderRequest(): boolean { + const prerender = _serverBuild?.prerender; + if (!prerender?.length) return false; + + const url = getIsolationScope().getScopeData().sdkProcessingMetadata.normalizedRequest?.url; + const pathname = url ? parseStringToURLObject(url)?.pathname : undefined; + if (!pathname) return false; + + const requestPath = withoutTrailingSlash(pathname); + return prerender.some(path => withoutTrailingSlash(path) === requestPath); +} + /** @internal */ export function registerServerBuildGlobal(): void { (GLOBAL_OBJ as GlobalObjWithBuildCapture)[GLOBAL_KEY] = setServerBuild; diff --git a/packages/react-router/src/server/wrapSentryHandleRequest.ts b/packages/react-router/src/server/wrapSentryHandleRequest.ts index 8a4af55ce62e..f40d56cad735 100644 --- a/packages/react-router/src/server/wrapSentryHandleRequest.ts +++ b/packages/react-router/src/server/wrapSentryHandleRequest.ts @@ -8,6 +8,7 @@ import { } from '@sentry/core'; import { flushIfServerless } from '@sentry/core/server'; import type { AppLoadContext, EntryContext, RouterContextProvider } from 'react-router'; +import { registerServerBuildGlobal } from './serverBuild'; import { isInstrumentationApiUsed } from './serverGlobals'; type OriginalHandleRequestWithoutMiddleware = ( @@ -53,6 +54,11 @@ export function wrapSentryHandleRequest( export function wrapSentryHandleRequest( originalHandle: OriginalHandleRequestWithoutMiddleware | OriginalHandleRequestWithMiddleware, ): OriginalHandleRequestWithoutMiddleware | OriginalHandleRequestWithMiddleware { + // `entry.server` is evaluated before the server build module, so the build's capture call at the + // end of that module finds this. Runtimes without the Node server integration (Cloudflare) only + // register it here. + registerServerBuildGlobal(); + return async function sentryInstrumentedHandleRequest( request: Request, responseStatusCode: number, @@ -60,8 +66,11 @@ export function wrapSentryHandleRequest( routerContext: EntryContext, loadContext: AppLoadContext | RouterContextProvider, ) { - const parameterizedPath = - routerContext?.staticHandlerContext?.matches?.[routerContext.staticHandlerContext.matches.length - 1]?.route.path; + const matches = routerContext?.staticHandlerContext?.matches; + // An index route has no `path` of its own and renders at its nearest ancestor's path, or at `/`. + const parameterizedPath = matches?.length + ? ([...matches].reverse().find(match => match.route.path)?.route.path ?? '/') + : undefined; const activeSpan = getActiveSpan(); const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; diff --git a/packages/react-router/src/vite/makeEnableSourceMapsPlugin.ts b/packages/react-router/src/vite/makeEnableSourceMapsPlugin.ts index aaae86c09b76..719acb397035 100644 --- a/packages/react-router/src/vite/makeEnableSourceMapsPlugin.ts +++ b/packages/react-router/src/vite/makeEnableSourceMapsPlugin.ts @@ -11,9 +11,7 @@ export function makeEnableSourceMapsPlugin(options: SentryReactRouterBuildOption enforce: 'post', config(viteConfig) { return { - ...viteConfig, build: { - ...viteConfig.build, sourcemap: getUpdatedSourceMapSettings(viteConfig, options), }, }; diff --git a/packages/react-router/src/vite/makeServerBuildCapturePlugin.ts b/packages/react-router/src/vite/makeServerBuildCapturePlugin.ts index b22fc715d782..8811dd13780a 100644 --- a/packages/react-router/src/vite/makeServerBuildCapturePlugin.ts +++ b/packages/react-router/src/vite/makeServerBuildCapturePlugin.ts @@ -23,10 +23,11 @@ export function makeServerBuildCapturePlugin(): Plugin { return null; } - // `routes` is a module-scope export in the virtual:react-router/server-build module + // `routes` and `prerender` are module-scope exports in the virtual:react-router/server-build + // module. `prerender` is guarded with `typeof` because older React Router versions don't declare it. const injectedCode = `${code} if (typeof globalThis !== 'undefined' && typeof globalThis["${GLOBAL_KEY}"] === 'function') { - globalThis["${GLOBAL_KEY}"]({ routes }); + globalThis["${GLOBAL_KEY}"]({ routes, prerender: typeof prerender !== 'undefined' ? prerender : undefined }); } `; diff --git a/packages/react-router/test/server/serverBuild.test.ts b/packages/react-router/test/server/serverBuild.test.ts index 80eb7e2028b7..d18f9d40f799 100644 --- a/packages/react-router/test/server/serverBuild.test.ts +++ b/packages/react-router/test/server/serverBuild.test.ts @@ -1,8 +1,9 @@ -import { GLOBAL_OBJ } from '@sentry/core'; +import { getIsolationScope, GLOBAL_OBJ } from '@sentry/core'; import { afterEach, describe, expect, it } from 'vitest'; import { _resetServerBuild, getMiddlewareName, + isPrerenderRequest, isServerBuildLike, registerServerBuildGlobal, setServerBuild, @@ -61,6 +62,38 @@ describe('serverBuild', () => { }); }); + describe('isPrerenderRequest', () => { + afterEach(() => { + getIsolationScope().setSDKProcessingMetadata({ normalizedRequest: undefined }); + }); + + it('is true when the current request path is in the prerender list, with or without a trailing slash', () => { + setServerBuild({ routes: {}, prerender: ['/performance/static', '/about/'] }); + + getIsolationScope().setSDKProcessingMetadata({ + normalizedRequest: { url: 'http://localhost/performance/static/' }, + }); + expect(isPrerenderRequest()).toBe(true); + + getIsolationScope().setSDKProcessingMetadata({ normalizedRequest: { url: 'http://localhost/about' } }); + expect(isPrerenderRequest()).toBe(true); + }); + + it('is false for other paths, without a prerender list, or without a request', () => { + setServerBuild({ routes: {}, prerender: ['/performance/static'] }); + expect(isPrerenderRequest()).toBe(false); + + getIsolationScope().setSDKProcessingMetadata({ normalizedRequest: { url: 'http://localhost/performance' } }); + expect(isPrerenderRequest()).toBe(false); + + setServerBuild({ routes: {} }); + getIsolationScope().setSDKProcessingMetadata({ + normalizedRequest: { url: 'http://localhost/performance/static' }, + }); + expect(isPrerenderRequest()).toBe(false); + }); + }); + describe('registerServerBuildGlobal', () => { it('should register a global callback that calls setServerBuild', () => { registerServerBuildGlobal(); diff --git a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts index c48bedef8181..6fa1f2ef1026 100644 --- a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts +++ b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts @@ -74,6 +74,45 @@ describe('wrapSentryHandleRequest', () => { }); }); + test('should name an index route after its nearest ancestor path, or `/`', async () => { + const mockRootSpan = { setAttributes: vi.fn() }; + + (getActiveSpan as unknown as ReturnType).mockReturnValue({}); + (getRootSpan as unknown as ReturnType).mockReturnValue(mockRootSpan); + + const wrappedHandler = wrapSentryHandleRequest(vi.fn().mockResolvedValue('test')); + + const rootIndexContext = { + staticHandlerContext: { matches: [{ route: { path: '' } }, { route: { index: true } }] }, + } as any; + await wrappedHandler(new Request('https://nacho.queso/'), 200, new Headers(), rootIndexContext, {} as any); + + expect(mockRootSpan.setAttributes).toHaveBeenLastCalledWith({ + [HTTP_ROUTE]: '/', + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.request_handler', + }); + + const nestedIndexContext = { + staticHandlerContext: { + matches: [{ route: { path: '' } }, { route: { path: 'dashboard' } }, { route: { index: true } }], + }, + } as any; + await wrappedHandler( + new Request('https://nacho.queso/dashboard'), + 200, + new Headers(), + nestedIndexContext, + {} as any, + ); + + expect(mockRootSpan.setAttributes).toHaveBeenLastCalledWith({ + [HTTP_ROUTE]: '/dashboard', + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.request_handler', + }); + }); + test('should not set span attributes when parameterized path does not exist', async () => { const mockActiveSpan = {}; const mockRootSpan = { setAttributes: vi.fn() }; diff --git a/packages/react-router/test/vite/makeServerBuildCapturePlugin.test.ts b/packages/react-router/test/vite/makeServerBuildCapturePlugin.test.ts index ab77da695dc0..f1adc360477f 100644 --- a/packages/react-router/test/vite/makeServerBuildCapturePlugin.test.ts +++ b/packages/react-router/test/vite/makeServerBuildCapturePlugin.test.ts @@ -45,7 +45,7 @@ describe('makeServerBuildCapturePlugin', () => { expect(result).not.toBeNull(); expect(result.code).toContain(SERVER_BUILD_CODE); expect(result.code).toContain('__sentrySetServerBuild'); - expect(result.code).toContain('({ routes })'); + expect(result.code).toContain("({ routes, prerender: typeof prerender !== 'undefined' ? prerender : undefined })"); expect(result.map).toBeNull(); }); }); diff --git a/packages/react-router/test/vite/sourceMaps.test.ts b/packages/react-router/test/vite/sourceMaps.test.ts index ae73ef43f5c4..aa23f9c8bed0 100644 --- a/packages/react-router/test/vite/sourceMaps.test.ts +++ b/packages/react-router/test/vite/sourceMaps.test.ts @@ -1,4 +1,7 @@ +// @vitest-environment node import type { SentryVitePluginOptions } from '@sentry/bundler-plugins/vite'; +import type { UserConfig } from 'vite'; +import { mergeConfig } from 'vite'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getUpdatedSourceMapSettings, makeEnableSourceMapsPlugin } from '../../src/vite/makeEnableSourceMapsPlugin'; @@ -31,6 +34,34 @@ describe('makeEnableSourceMapsPlugin()', () => { expect(enableSourceMapPlugin?.enforce).toEqual('post'); expect(enableSourceMapPlugin?.config).toEqual(expect.any(Function)); }); + + it.each([undefined, false, true, 'hidden', 'inline'] as const)( + 'preserves user config without duplicating arrays when sourcemap is %s', + sourcemap => { + const plugin = makeEnableSourceMapsPlugin({}); + const configHook = plugin.config as (config: UserConfig) => UserConfig; + const userConfig: UserConfig = { + optimizeDeps: { include: ['react'] }, + build: { + sourcemap, + outDir: 'custom-dist', + rollupOptions: { plugins: [{ name: 'asset-transform' }] }, + }, + }; + + const merged = mergeConfig(userConfig, configHook(userConfig)); + + expect(merged).toEqual({ + optimizeDeps: { include: ['react'] }, + build: { + sourcemap: sourcemap ?? 'hidden', + outDir: 'custom-dist', + rollupOptions: { plugins: [{ name: 'asset-transform' }] }, + rolldownOptions: { plugins: [{ name: 'asset-transform' }] }, + }, + }); + }, + ); }); describe('getUpdatedSourceMapSettings', () => { diff --git a/packages/react/package.json b/packages/react/package.json index 9b0db4e74711..fcc90a1fc7ae 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -59,7 +59,7 @@ "dependencies": { "@sentry/browser": "11.2.0", "@sentry/core": "11.2.0", - "@sentry/conventions": "^0.24.0" + "@sentry/conventions": "^0.25.0" }, "peerDependencies": { "react": "17.x || 18.x || 19.x", diff --git a/packages/remix/package.json b/packages/remix/package.json index 930d812dbd70..42006d9a9271 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -7,7 +7,8 @@ "author": "Sentry", "license": "MIT", "bin": { - "sentry-upload-sourcemaps": "scripts/sentry-upload-sourcemaps.js" + "sentry-upload-sourcemaps": "scripts/sentry-upload-sourcemaps.js", + "sentry-remix-v3-upload-sourcemaps": "scripts/sentry-remix-v3-upload-sourcemaps.mjs" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" @@ -61,7 +62,7 @@ "./v3/client": { "import": { "types": "./build/types/v3/index.client.d.ts", - "default": "./build/esm/v3/index.client.js" + "default": "./build/esm/v3/client-bundle.js" } }, "./v3/node": { @@ -77,7 +78,7 @@ "@remix-run/router": "^1.23.4", "@sentry/browser": "11.2.0", "@sentry/bundler-plugins": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "@sentry/node": "11.2.0", "@sentry/react": "11.2.0", diff --git a/packages/remix/rollup.npm.config.mjs b/packages/remix/rollup.npm.config.mjs index d6992abc3302..40b06766961c 100644 --- a/packages/remix/rollup.npm.config.mjs +++ b/packages/remix/rollup.npm.config.mjs @@ -1,3 +1,5 @@ +/* eslint-disable import/no-named-as-default */ +import nodeResolve from '@rollup/plugin-node-resolve'; import { defineConfig } from 'rollup'; import { makeBaseNPMConfig, makeNPMConfigVariants, makeOrchestrionLoader } from '@sentry-internal/rollup-utils'; @@ -8,6 +10,42 @@ const v3NodeEntry = defineConfig({ output: { format: 'esm', file: 'build/v3-node.mjs' }, }); +/** + * The Remix 3 browser entry, bundled into a single self contained ES module. + * + * Remix 3 has no bundler. Its asset server serves one HTTP request per module and never drops dead + * code, because it only ever looks at one module at a time. The ordinary `preserveModules` output + * therefore costs an app 255 requests and about 364 KB gzipped of `@sentry/*`; bundled here it is 2 + * requests and 54 KB. Publish time is the only place that reduction can happen. + * + * Runs over `build/esm/v3/index.client.js` rather than the TypeScript source, to reuse the transpilation + * the main config already did. That is why it has to come last in this array. + * + * Left unminified and without a source map on purpose. The asset server minifies what it serves and + * builds its own map chain, so a pre-minified file with its own map would add a second chain for + * nothing. + */ +const v3ClientBundle = defineConfig({ + input: 'build/esm/v3/index.client.js', + // The channel shim has to stay external. Orchestrion's browser transform imports that file by URL + // into every instrumented module, so an inlined copy would leave the page with two shims and two + // separate subscriber registries, and instrumentation would quietly do nothing. + external: id => /diagnosticsChannelShim/.test(id) || id === 'remix' || id.startsWith('@remix-run/'), + treeshake: { moduleSideEffects: false, propertyReadSideEffects: false }, + plugins: [nodeResolve({ browser: true, exportConditions: ['browser', 'import', 'default'] })], + // Emitted inside `build/esm/v3/`, not at `build/`: the one import it keeps is the relative path to + // the channel shim, which only resolves from there. + output: { file: 'build/esm/v3/client-bundle.js', format: 'esm' }, + onwarn(warning, warn) { + // `this` is undefined in the bundled output of some dependencies, and the graph has cycles. Both + // are harmless here and would otherwise bury real warnings. + if (warning.code === 'THIS_IS_UNDEFINED' || warning.code === 'CIRCULAR_DEPENDENCY') { + return; + } + warn(warning); + }, +}); + // We rely on esbuild's defaults for JSX (`jsx: 'transform'` = classic runtime, no // __self/__source attributes). React 19 prefers the new automatic transform, but switching // to it would break React 17 support — so we intentionally stay on classic for now. @@ -25,6 +63,8 @@ export default [ 'src/vite/index.ts', 'src/v3/index.server.ts', 'src/v3/index.client.ts', + // An explicit entry keeps its default export, which only orchestrion's injected import uses. + 'src/v3/client/diagnosticsChannelShim.ts', ], packageSpecificConfig: { external: ['react-router', 'react-router-dom', 'react', 'react/jsx-runtime'], @@ -39,4 +79,5 @@ export default [ }), ), ...makeOrchestrionLoader('./build'), + v3ClientBundle, ]; diff --git a/packages/remix/scripts/sentry-remix-v3-upload-sourcemaps.mjs b/packages/remix/scripts/sentry-remix-v3-upload-sourcemaps.mjs new file mode 100644 index 000000000000..8cc963fd57ea --- /dev/null +++ b/packages/remix/scripts/sentry-remix-v3-upload-sourcemaps.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ +// Emits a Remix 3 app's browser module graph to a directory and uploads it to Sentry. +// +// Must run under the Sentry entry, which provides Remix's TypeScript loader and patches the asset +// server so the emitted modules carry debug IDs: +// +// node --import @sentry/remix/v3/node ./node_modules/.bin/sentry-remix-v3-upload-sourcemaps \ +// --entry app/actions/public/entry.ts --release "$RELEASE" +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; + +class UsageError extends Error {} + +let outDir; +let keepOutput = false; + +try { + const values = parseOptions(); + keepOutput = Boolean(values['keep-output'] || values['out-dir']); + + const { emitAssets } = await import('@sentry/remix/v3'); + + let assetsModule; + try { + assetsModule = await import(pathToFileURL(path.resolve(values['assets-module'])).href); + } catch (error) { + if (error?.code === 'ERR_UNKNOWN_FILE_EXTENSION') { + fail(`could not load ${values['assets-module']}. Run this command with \`node --import @sentry/remix/v3/node\`.`); + } + throw error; + } + const assets = assetsModule[values['assets-export']]; + if (typeof assets?.getPreloads !== 'function' || typeof assets?.fetch !== 'function') { + fail(`${values['assets-module']} does not export an asset server named "${values['assets-export']}"`); + } + + outDir = values['out-dir'] ?? fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-remix-v3-assets-')); + const emitted = await emitAssets({ assets, entries: values.entry, outDir }); + const maps = emitted.filter(asset => asset.mapFile).length; + const debugIds = emitted.filter(asset => asset.debugId).length; + console.log( + `[sentry] emitted ${emitted.length} modules, ${maps} source maps, ${debugIds} debug IDs${keepOutput ? ` to ${outDir}` : ''}`, + ); + + if (emitted.length > 0 && debugIds === 0) { + // The one failure that is otherwise silent: an unpatched asset server emits plain modules. + fail( + 'no debug IDs were emitted. Run this command with `node --import @sentry/remix/v3/node` so the asset server is instrumented.', + ); + } + if (debugIds < emitted.length || maps < emitted.length) { + // A warning, not a failure: `sourceMaps: false` yields no maps on purpose. + console.warn( + `[sentry] ${emitted.length - debugIds} modules without a debug ID and ${emitted.length - maps} without a source map; those will not symbolicate.`, + ); + } + + if (values['dry-run']) { + console.log('[sentry] --dry-run, skipping upload'); + } else { + const { createSentrySDK } = await import('sentry'); + const sentry = createSentrySDK({ url: values.url, org: values.org, project: values.project }); + const release = values.release ?? (await sentry.release['propose-version']()).version; + try { + // The project has to be named here; the SDK default does not reach release creation. + await sentry.release.create({ orgVersion: release, project: values.project }); + // Modules keep their served names (`.ts`, `.tsx`), which the upload skips by default. Only module + // extensions go here: the upload pairs each module with the `.map` sibling `emitAssets` wrote. + const ext = [...new Set(emitted.map(asset => path.extname(asset.file)))].join(','); + await sentry.sourcemap.upload({ directory: outDir, release, ext }); + await sentry.release.finalize({ orgVersion: release }); + } catch (error) { + fail(error?.message ?? String(error)); + } + console.log(`[sentry] uploaded release ${release}`); + } +} catch (error) { + if (!(error instanceof UsageError)) { + throw error; + } + console.error(`[sentry] ${error.message}`); + process.exitCode = 1; +} finally { + if (outDir && !keepOutput) { + fs.rmSync(outDir, { recursive: true, force: true }); + } +} + +function parseOptions() { + let values; + try { + ({ values } = parseArgs({ + options: { + entry: { type: 'string', multiple: true }, + 'assets-module': { type: 'string', default: './app/assets.ts' }, + 'assets-export': { type: 'string', default: 'assets' }, + release: { type: 'string' }, + org: { type: 'string' }, + project: { type: 'string' }, + url: { type: 'string' }, + 'out-dir': { type: 'string' }, + 'keep-output': { type: 'boolean', default: false }, + 'dry-run': { type: 'boolean', default: false }, + }, + })); + } catch (error) { + fail(error.message); + } + if (!values.entry?.length) { + fail('at least one --entry is required, e.g. --entry app/actions/public/entry.ts'); + } + return values; +} + +function fail(message) { + throw new UsageError(message); +} diff --git a/packages/remix/src/client/routeProvider.ts b/packages/remix/src/client/routeProvider.ts new file mode 100644 index 000000000000..5cc470a9c5a0 --- /dev/null +++ b/packages/remix/src/client/routeProvider.ts @@ -0,0 +1,13 @@ +import type { RouteProvider } from '@sentry/react'; +import { createUrlRouteProvider } from '@sentry/react'; +import { maybeParameterizeRemixRoute } from './remixRouteParameterization'; + +/** + * A route provider backed by the route manifest the Vite plugin injects at build time. + * + * The manifest is on the global object before `Sentry.init` runs, so this needs no router and no + * tracing integration: registering it is what lets anything else in the SDK name a route. + */ +export function createRemixRouteProvider(): RouteProvider { + return createUrlRouteProvider(url => maybeParameterizeRemixRoute(url.pathname)); +} diff --git a/packages/remix/src/client/sdk.ts b/packages/remix/src/client/sdk.ts index d09ec6786ce4..46bf5a92c501 100644 --- a/packages/remix/src/client/sdk.ts +++ b/packages/remix/src/client/sdk.ts @@ -2,6 +2,7 @@ import type { Client } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; import { init as reactInit } from '@sentry/react'; import type { RemixOptions } from '../utils/remixOptions'; +import { createRemixRouteProvider } from './routeProvider'; /** * Initializes the Remix SDK. @@ -10,6 +11,9 @@ import type { RemixOptions } from '../utils/remixOptions'; */ export function init(options: RemixOptions): Client | undefined { const opts = { + // The manifest is injected at build time, so route parameterization works from `init` on, even with + // tracing disabled. + routeProvider: createRemixRouteProvider(), ...options, environment: options.environment || process.env.NODE_ENV, }; diff --git a/packages/remix/src/v3/assetServer.ts b/packages/remix/src/v3/assetServer.ts new file mode 100644 index 000000000000..88ce5060662a --- /dev/null +++ b/packages/remix/src/v3/assetServer.ts @@ -0,0 +1,258 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import { consoleSandbox } from '@sentry/core'; +import { remixV3Channels } from '@sentry/server-utils/orchestrion/config'; +import { addDebugIdToSourceMap, findDebugId, getDebugId, injectDebugIdSnippet } from './debugId'; +import { getShimUrl, isOrchestrionLoader, orchestrionLoader, resolveShimPath } from './orchestrionLoader'; + +// The subset of `@remix-run/assets` types used here. `remix` is an optional peer dependency, so +// they are restated rather than imported. +interface ModuleLoadContext { + moduleUrl?: string; + [key: string]: unknown; +} + +interface ModuleLoadResult { + format: string | null | undefined; + shortCircuit?: boolean; + source?: string | ArrayBuffer | ArrayBufferView; +} + +type ModuleLoader = ( + url: string, + context: ModuleLoadContext, + nextLoad: (url: string, context?: Partial) => ModuleLoadResult, +) => ModuleLoadResult; + +interface AssetServerOptions { + allowPackages?: readonly string[]; + basePath?: string; + rootDir?: string; + mounts?: Readonly>; + // `false` is not in Remix's type, but is how an app tells Sentry it wants no source maps at all, + // since leaving the option out now means hidden source maps. + sourceMaps?: 'inline' | 'external' | false; + scripts?: { loaders?: readonly ModuleLoader[]; external?: readonly string[]; [key: string]: unknown }; + [key: string]: unknown; +} + +interface AssetServer { + fetch: (request: Request) => Promise; +} + +interface CreateAssetServerContext { + arguments: unknown[]; + result?: unknown; + _sentryHideSourceMaps?: boolean; +} + +// The asset server appends it after minification, so it is always the last line. +const SOURCE_MAPPING_URL_REGEX = /\n\/\/# sourceMappingURL=\S+\s*$/; + +const MAX_STAMPED_BODIES = 2000; +const SDK_PACKAGE = '@sentry/remix'; + +let instrumented = false; + +/** + * Makes every Remix 3 asset server created from now on serve browser modules that carry debug IDs. + * + * Source maps follow the other meta framework SDKs: + * - `sourceMaps: false` keeps them off, with a warning that stack traces stay minified. + * - `'inline'` or `'external'` is kept as the app configured it. + * - Left out, they are generated but hidden: modules do not reference them and `.map` requests are + * not served, so the source only reaches Sentry. + * + * Has to run before the app's first `createAssetServer()` call, which usually happens while its + * modules are imported. `Sentry.init()` is too late for that, so the `@sentry/remix/v3/node` entry + * calls this. + */ +export function instrumentAssetServer(): void { + if (instrumented) { + return; + } + instrumented = true; + + // Node rethrows anything a channel subscriber throws as an uncaught exception, which would kill an + // app that runs fine without Sentry. Frozen options or an unexpected server shape reach here, so + // the asset server is left as it was instead. + diagnosticsChannel.tracingChannel(remixV3Channels.REMIX_V3_CREATE_ASSET_SERVER).subscribe({ + start(data) { + try { + const context = data as CreateAssetServerContext; + const options = context.arguments[0] as AssetServerOptions | undefined; + context._sentryHideSourceMaps = options?.sourceMaps === undefined; + context.arguments[0] = withDebugIdOptions(options); + } catch { + // Ignored on purpose. + } + }, + end(data) { + try { + const { result, _sentryHideSourceMaps } = data as CreateAssetServerContext; + if (result) { + stampServedAssets(result as AssetServer, { hideSourceMaps: Boolean(_sentryHideSourceMaps) }); + } + } catch { + // Ignored on purpose. + } + }, + asyncStart() {}, + asyncEnd() {}, + error() {}, + }); +} + +/** + * Injects the debug ID snippet into every module the asset server compiles. + * + * Loaders run after the TypeScript transform and before minification, so the snippet is minified + * along with the module, and its ID is a hash of the compiled source. The module URL is part of the + * hash so two identical files get IDs of their own, since their source maps differ. + */ +export const debugIdLoader: ModuleLoader = (url, context, nextLoad) => { + const result = nextLoad(url, context); + if (result.format !== 'module' || typeof result.source !== 'string') { + return result; + } + + const debugId = getDebugId(`${context.moduleUrl ?? url}\n${result.source}`); + return { ...result, source: injectDebugIdSnippet(result.source, debugId) }; +}; + +function withDebugIdOptions(options: AssetServerOptions | undefined): AssetServerOptions | undefined { + if (!options) { + return options; + } + + if (options.sourceMaps === false) { + consoleSandbox(() => { + // oxlint-disable-next-line no-console + console.warn( + '[Sentry] Source maps are disabled in your asset server (`sourceMaps: false`). Sentry will not override this, so client stack traces stay minified.', + ); + }); + } + + // No allowed packages means no `node_modules` module is served, so nothing to transform. Otherwise + // the shim must be allowed too, or its injected import 404s and takes the client runtime down. + const servesPackages = (options.allowPackages?.length ?? 0) > 0; + const allowPackages = + servesPackages && !options.allowPackages?.includes(SDK_PACKAGE) + ? [...(options.allowPackages ?? []), SDK_PACKAGE] + : options.allowPackages; + + const shimUrl = servesPackages + ? getShimUrl(options.basePath ?? '/', options.rootDir ?? process.cwd(), options.mounts, resolveShimPath()) + : undefined; + if (servesPackages && shimUrl === undefined) { + consoleSandbox(() => { + // oxlint-disable-next-line no-console + console.warn( + "[Sentry] The browser channel shim is outside the asset server's node_modules mount, so browser modules are served without instrumentation. Component render errors need `captureRuntimeErrors(app)`.", + ); + }); + } + const loaders = (options.scripts?.loaders ?? []).filter( + loader => loader !== debugIdLoader && !isOrchestrionLoader(loader), + ); + const external = (options.scripts?.external ?? []).filter(entry => entry !== shimUrl); + + return { + ...options, + ...(allowPackages !== options.allowPackages && { allowPackages }), + // Hidden unless the app chose otherwise, see `stampServedAssets`. + sourceMaps: options.sourceMaps ?? 'external', + scripts: { + ...options.scripts, + // The transform first, the debug ID last, so the ID covers the transformed module. + loaders: + shimUrl === undefined ? [...loaders, debugIdLoader] : [...loaders, orchestrionLoader(shimUrl), debugIdLoader], + // Kept as a URL by the compiler. A bare specifier would not resolve from inside an instrumented + // package under pnpm. + external: shimUrl === undefined ? external : [shimUrl, ...external], + }, + }; +} + +/** + * The minifier drops comments and the asset server rebuilds source maps after the loaders ran, so + * the `//# debugId=` comment and the source map's `debugId` field, which is what `sentry-cli` reads, + * are added to the served response instead. + */ +/** + * The asset server's own `fetch`, before stamping. The emitter needs it because stamping hides source + * maps by default, and the upload must still get them. + * @internal + */ +export const UNSTAMPED_FETCH: unique symbol = Symbol.for('sentry.remix.unstampedFetch'); + +function stampServedAssets(server: AssetServer, { hideSourceMaps }: { hideSourceMaps: boolean }): void { + const fetchAsset = server.fetch; + (server as AssetServer & { [UNSTAMPED_FETCH]?: AssetServer['fetch'] })[UNSTAMPED_FETCH] = fetchAsset; + // The asset server memoizes compiled modules and identifies each version by its ETag, so the + // stamped body is memoized the same way. Without this every hit copied the module body again. + const stamped = new Map(); + + server.fetch = async request => { + const response = await fetchAsset(request); + if (response?.status !== 200 || request.method !== 'GET') { + return response; + } + + const url = new URL(request.url); + const contentType = response.headers.get('content-type') ?? ''; + const etag = response.headers.get('etag'); + + if (contentType.includes('javascript')) { + return withBody(response, await remember(etag, async () => stampModule(await response.text()))); + } + + if (url.pathname.endsWith('.map')) { + if (hideSourceMaps) { + // What the asset server returns for a path it does not serve. + return null; + } + + const body = await remember(etag, async () => { + // A source map does not contain the ID of its module, so it is read from the module itself. + url.pathname = url.pathname.slice(0, -'.map'.length); + const moduleResponse = await fetchAsset(new Request(url)); + const debugId = moduleResponse?.ok ? findDebugId(await moduleResponse.text()) : undefined; + const map = await response.text(); + return debugId ? addDebugIdToSourceMap(map, debugId) : map; + }); + return withBody(response, body); + } + + return response; + }; + + function stampModule(served: string): string { + const code = hideSourceMaps ? served.replace(SOURCE_MAPPING_URL_REGEX, '') : served; + const debugId = findDebugId(code); + return debugId ? `${code}\n//# debugId=${debugId}` : code; + } + + async function remember(etag: string | null, compute: () => Promise): Promise { + if (etag === null) { + return compute(); + } + const cached = stamped.get(etag); + if (cached !== undefined) { + return cached; + } + const body = await compute(); + // Bounded, because in watch mode every edit is a new ETag. + if (stamped.size >= MAX_STAMPED_BODIES) { + stamped.delete(stamped.keys().next().value as string); + } + stamped.set(etag, body); + return body; + } +} + +function withBody(response: Response, body: string): Response { + const headers = new Headers(response.headers); + headers.delete('content-length'); + return new Response(body, { status: response.status, statusText: response.statusText, headers }); +} diff --git a/packages/remix/src/v3/client/browserTracingIntegration.ts b/packages/remix/src/v3/client/browserTracingIntegration.ts new file mode 100644 index 000000000000..c9832db5c4cd --- /dev/null +++ b/packages/remix/src/v3/client/browserTracingIntegration.ts @@ -0,0 +1,106 @@ +import { + browserTracingIntegration as originalBrowserTracingIntegration, + startBrowserTracingNavigationSpan, + WINDOW, +} from '@sentry/browser'; +import { SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; +import { + type Client, + hasSpanStreamingEnabled, + type Integration, + NAVIGATION_SPAN_NAME_FALLBACK, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, +} from '@sentry/core'; + +type Options = Parameters[0]; + +/** + * Browser tracing for Remix 3. + * + * Page loads stay with the upstream integration, because they are ordinary document loads. Navigations + * do not: `remix/ui` intercepts links and form submissions through the Navigation API and never touches + * History, so the upstream handler never fires. + */ +export function browserTracingIntegration(options: Options = {}): Integration { + const integration = originalBrowserTracingIntegration({ ...options, instrumentNavigation: false }); + + return { + ...integration, + afterAllSetup(client) { + integration.afterAllSetup(client); + + if (options.instrumentNavigation !== false) { + instrumentNavigationApi(client); + } + }, + }; +} + +function instrumentNavigationApi(client: Client): void { + const navigation = (WINDOW as WindowWithNavigation).navigation; + if (!navigation) { + // Without the Navigation API the runtime does full document loads, which are page loads already. + return; + } + + navigation.addEventListener('navigate', event => { + const url = event.destination?.url; + if (!url || !isRuntimeNavigation(event, url)) { + return; + } + + startBrowserTracingNavigationSpan( + client, + { + // Remix 3 gives the browser no route to name this after: `remix/ui` exposes no matched route + // and never matches client side. Passing the server's pattern down is tracked in (#24872). + name: hasSpanStreamingEnabled(client) ? NAVIGATION_SPAN_NAME_FALLBACK : pathnameOf(url) || '/', + attributes: { + [SENTRY_SEGMENT_NAME_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.remix_v3', + }, + }, + // The span needs the destination: `location` still points at the previous page until the + // navigation finishes. + { url }, + ); + }); +} + +/** + * Whether the runtime will keep this navigation inside the current document. + * + * The same three conditions `startNavigationListener` in `@remix-run/ui` checks before intercepting. + * What it declines becomes a new document load, which already gets a page load span, so a navigation + * span here would count the same click twice. + */ +function isRuntimeNavigation(event: NavigateEventLike, url: string): boolean { + // `'remix-document-reload'` is the `info` value the runtime tags its own document reloads with. + return event.canIntercept && event.info !== 'remix-document-reload' && isSameOrigin(url); +} + +function isSameOrigin(url: string): boolean { + try { + return new URL(url).origin === WINDOW.location?.origin; + } catch { + return false; + } +} + +function pathnameOf(url: string): string | undefined { + try { + return new URL(url).pathname; + } catch { + return undefined; + } +} + +interface NavigateEventLike extends Event { + canIntercept: boolean; + info?: unknown; + destination?: { url: string }; +} + +type WindowWithNavigation = typeof WINDOW & { + navigation?: { addEventListener(type: 'navigate', listener: (event: NavigateEventLike) => void): void }; +}; diff --git a/packages/remix/src/v3/client/diagnosticsChannelShim.ts b/packages/remix/src/v3/client/diagnosticsChannelShim.ts new file mode 100644 index 000000000000..bd3b415bb456 --- /dev/null +++ b/packages/remix/src/v3/client/diagnosticsChannelShim.ts @@ -0,0 +1,121 @@ +/** + * A browser stand-in for `node:diagnostics_channel`. + * + * Orchestrion's transform injects `import dc from ''` into every instrumented module and + * drives it through Node's `tracingChannel` API. The browser has no `node:diagnostics_channel`, so + * without this shim the transform cannot run on browser code. + * + * This implements exactly what the injected code calls, which is not the `traceSync` helpers but the + * per-event channels under them: + * + * ```js + * // The gate is true when ANY of the five event channels has a subscriber, so subscribing to `end` + * // alone is enough to get the call wrapped. + * if (!(ch.start.hasSubscribers || ch.end.hasSubscribers || ch.asyncStart.hasSubscribers + * || ch.asyncEnd.hasSubscribers || ch.error.hasSubscribers)) return traced(); + * return ch.start.runStores(ctx, () => { + * try { ctx.result = traced(); } + * catch (err) { ctx.error = err; ch.error.publish(ctx); throw err; } + * finally { ch.end.publish(ctx); } + * }); + * ``` + * + * The browser has no `AsyncLocalStorage`, so `runStores` publishes and then calls the callback directly + * instead of entering a store. The injected code does not need a store: it passes state along on the + * `context` object. + */ + +type Handler = (context: unknown) => void; + +interface Channel { + readonly hasSubscribers: boolean; + publish(context: unknown): void; + runStores(context: unknown, fn: (...args: unknown[]) => T, thisArg?: unknown, ...args: unknown[]): T; + subscribe(handler: Handler): void; + unsubscribe(handler: Handler): void; +} + +const EVENTS = ['start', 'end', 'asyncStart', 'asyncEnd', 'error'] as const; +type EventName = (typeof EVENTS)[number]; + +export type TracingChannelSubscribers = Partial>; + +export interface BrowserTracingChannel extends Record { + subscribe(subscribers: TracingChannelSubscribers): void; + unsubscribe(subscribers: TracingChannelSubscribers): void; +} + +function createChannel(): Channel { + const handlers = new Set(); + + return { + get hasSubscribers(): boolean { + return handlers.size > 0; + }, + publish(context) { + // Snapshot: a subscriber may unsubscribe itself while being notified. + for (const handler of Array.from(handlers)) { + try { + handler(context); + } catch { + // A throwing subscriber must never break the instrumented library. + } + } + }, + runStores(context, fn, thisArg, ...args) { + this.publish(context); + return fn.apply(thisArg, args); + }, + subscribe(handler) { + handlers.add(handler); + }, + unsubscribe(handler) { + handlers.delete(handler); + }, + }; +} + +declare global { + // `var`, because only a `var` here becomes a property of `globalThis`. + // oxlint-disable-next-line no-var + var __SENTRY_REMIX_DC_REGISTRY__: Map | undefined; +} + +// On `globalThis`, so a second copy of this module loaded under another URL shares the subscribers +// instead of splitting them. Keyed by the channel names the transform compiles in, so it stays small. +const registry = (globalThis.__SENTRY_REMIX_DC_REGISTRY__ ??= new Map()); + +/** Create (or look up) a tracing channel by name. */ +export function tracingChannel(name: string): BrowserTracingChannel { + const existing = registry.get(name); + if (existing) { + return existing; + } + + const channels = Object.fromEntries(EVENTS.map(event => [event, createChannel()])) as Record; + + const channel: BrowserTracingChannel = { + ...channels, + subscribe(subscribers) { + for (const event of EVENTS) { + const handler = subscribers[event]; + if (handler) { + channels[event].subscribe(handler); + } + } + }, + unsubscribe(subscribers) { + for (const event of EVENTS) { + const handler = subscribers[event]; + if (handler) { + channels[event].unsubscribe(handler); + } + } + }, + }; + + registry.set(name, channel); + return channel; +} + +export default { tracingChannel }; diff --git a/packages/remix/src/v3/client/errors.ts b/packages/remix/src/v3/client/errors.ts new file mode 100644 index 000000000000..fd636cacba27 --- /dev/null +++ b/packages/remix/src/v3/client/errors.ts @@ -0,0 +1,74 @@ +import { captureException } from '@sentry/browser'; +import { tracingChannel } from './diagnosticsChannelShim'; + +/** The `AppRuntime` returned by `run()` from `remix/ui`, an EventTarget emitting `error`. */ +export interface AppRuntimeLike { + addEventListener(type: 'error', listener: (event: Event) => void): void; +} + +const MECHANISM_TYPE = 'auto.ui.remix_v3'; + +// The channel orchestrion will inject into `@remix-run/ui`'s `run()` once the asset server transforms +// browser modules. Nothing publishes to it yet, so the subscription below is dormant. The name is +// written out because the channel names live in `@sentry/server-utils`, which must not reach the +// browser. +const UI_RUN_CHANNEL = 'orchestrion:@remix-run/ui:run'; + +const attached = new WeakSet(); + +// `subscribe()` takes a fresh object literal and the channel keys handlers by identity, so a second +// call would add a second handler rather than replace the first. +let subscribed = false; + +/** + * Report a Remix 3 client runtime's errors to Sentry. + * + * The runtime sends every render, scheduler, frame and hydration error to the event target `run()` + * returns. Dispatching an event does not rethrow, so `window.onerror` and `unhandledrejection` never + * see them, and without this listener the SDK sees nothing from the component layer. + * + * Called by {@link instrumentClientRuntime}, and exported for apps that do not serve their browser + * modules through an instrumented asset server. + */ +export function captureRuntimeErrors(app: AppRuntimeLike): void { + if (attached.has(app)) { + return; + } + attached.add(app); + + app.addEventListener('error', event => { + captureException(readComponentError(event), { + mechanism: { handled: false, type: MECHANISM_TYPE }, + }); + }); +} + +/** + * Attach to every `run()` call automatically, so the app never has to call `captureRuntimeErrors()` + * itself. A no-op when the browser module was not transformed, because the channel never fires. + */ +export function instrumentClientRuntime(): void { + if (subscribed) { + return; + } + subscribed = true; + + tracingChannel(UI_RUN_CHANNEL).subscribe({ + end(context) { + const app = (context as { result?: unknown }).result; + if (isAppRuntime(app)) { + captureRuntimeErrors(app); + } + }, + }); +} + +function isAppRuntime(value: unknown): value is AppRuntimeLike { + return typeof (value as AppRuntimeLike | undefined)?.addEventListener === 'function'; +} + +// The runtime puts the thrown value on `error`, and it may be any value, not just an `Error`. +function readComponentError(event: Event): unknown { + const error = (event as ErrorEvent).error; + return error !== undefined ? error : event; +} diff --git a/packages/remix/src/v3/client/sdk.ts b/packages/remix/src/v3/client/sdk.ts new file mode 100644 index 000000000000..cbf5560c1296 --- /dev/null +++ b/packages/remix/src/v3/client/sdk.ts @@ -0,0 +1,35 @@ +import type { BrowserOptions } from '@sentry/browser'; +import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as browserInit } from '@sentry/browser'; +import { applySdkMetadata, type Client, type Integration } from '@sentry/core'; + +import { browserTracingIntegration } from './browserTracingIntegration'; +import { instrumentClientRuntime } from './errors'; + +/** + * Default integrations for the Remix 3 client SDK. + * + * Browser tracing is added here rather than left to the app, because the Navigation API variant is the + * only one that reports anything in Remix 3. Everything else is plain `@sentry/browser`. Nothing from + * `@sentry/react` applies: `remix/ui` is its own runtime, with no React and no reconciler to hook. + */ +export function getDefaultIntegrations(options: BrowserOptions): Integration[] { + return [...getBrowserDefaultIntegrations(options), browserTracingIntegration()]; +} + +/** Initialize the Sentry Remix 3 SDK in the browser. */ +export function init(options: BrowserOptions): Client | undefined { + const opts = { + ...options, + defaultIntegrations: options.defaultIntegrations ?? getDefaultIntegrations(options), + }; + + applySdkMetadata(opts, 'remix', ['remix', 'browser']); + + const client = browserInit(opts); + + // Subscribed before the app calls `run()`, so an app served through an instrumented asset server + // reports component errors without writing any Sentry code itself. + instrumentClientRuntime(); + + return client; +} diff --git a/packages/remix/src/v3/debugId.ts b/packages/remix/src/v3/debugId.ts new file mode 100644 index 000000000000..92094a9fef4c --- /dev/null +++ b/packages/remix/src/v3/debugId.ts @@ -0,0 +1,135 @@ +import { createHash } from 'node:crypto'; + +// Mirrors `stringToUUID` and `getDebugIdSnippet` in `@sentry/bundler-plugins/core`. Importing that +// entry at runtime would load the whole build plugin (a native parser, the Sentry CLI) into the server. + +const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +const DEBUG_ID_IDENTIFIER_REGEX = /sentry-dbid-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/; + +const INLINE_SOURCE_MAP_REGEX = /\n?\/\/# sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)\s*$/; + +interface SourceMap { + mappings: string; + sources?: string[]; + [key: string]: unknown; +} + +/** + * Hashes `source` into a UUID shaped ID. It has to be deterministic, because the source map upload + * runs in a different process than the server and must arrive at the same ID. + */ +export function getDebugId(source: string): string { + const hash = createHash('sha256').update(source).digest('hex'); + // RFC 4122 section 4.4: the variant nibble is one of 8, 9, a, b. + const variant = ['8', '9', 'a', 'b'][hash.charCodeAt(16) % 4] as string; + + return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-4${hash.slice(13, 16)}-${variant}${hash.slice(17, 20)}-${hash.slice(20, 32)}`; +} + +/** The snippet that registers a module's debug ID in `globalThis._sentryDebugIds`, keyed by its stack. */ +export function getDebugIdSnippet(debugId: string): string { + return `!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{};var n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="${debugId}",e._sentryDebugIdIdentifier="sentry-dbid-${debugId}");}catch(e){}}();`; +} + +/** Reads back the debug ID that {@link getDebugIdSnippet} put into a module, which survives minification. */ +export function findDebugId(code: string): string | undefined { + return code.match(DEBUG_ID_IDENTIFIER_REGEX)?.[1]; +} + +/** + * Puts the debug ID snippet on its own line at the top of `source`, so it runs before the module + * body and registers the ID even when that body throws. + * + * `source` may end in an inline source map, which is how the asset server hands a module to its + * loaders. The returned map then maps back to `source` and the asset server composes it with its + * own, so it only has to shift every line down by one. + */ +export function injectDebugIdSnippet(source: string, debugId: string): string { + const snippet = getDebugIdSnippet(debugId); + const inlineSourceMap = source.match(INLINE_SOURCE_MAP_REGEX); + + if (!inlineSourceMap?.[1]) { + return `${snippet}\n${source}`; + } + + const code = source.slice(0, inlineSourceMap.index); + const map = JSON.parse(Buffer.from(inlineSourceMap[1], 'base64').toString('utf8')) as SourceMap; + const shiftedMap = { + version: 3, + sources: map.sources?.slice(0, 1) ?? [], + names: [], + mappings: getShiftedIdentityMappings(map.mappings), + }; + + return `${snippet}\n${code}\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(shiftedMap)).toString('base64')}`; +} + +/** Adds the debug ID to a source map, under both field names the upload tooling reads. */ +export function addDebugIdToSourceMap(sourceMap: string, debugId: string): string { + const map = JSON.parse(sourceMap) as SourceMap; + map.debug_id = debugId; + map.debugId = debugId; + return JSON.stringify(map); +} + +/** + * Maps line `n + 1` of the output to line `n` of the input, at exactly the columns the input map + * has segments for. Composition only keeps positions both maps know about, so a coarser identity + * map would lose precision. + */ +function getShiftedIdentityMappings(mappings: string): string { + let previousLine = 0; + let previousColumn = 0; + + const lines = mappings.split(';').map((line, lineIndex) => { + let column = 0; + let previousGeneratedColumn = 0; + + return line + .split(',') + .filter(Boolean) + .map(segment => { + column += decodeFirstVlq(segment); + const encoded = `${encodeVlq(column - previousGeneratedColumn)}A${encodeVlq(lineIndex - previousLine)}${encodeVlq(column - previousColumn)}`; + previousGeneratedColumn = column; + previousLine = lineIndex; + previousColumn = column; + return encoded; + }) + .join(','); + }); + + return `;${lines.join(';')}`; +} + +// A segment's first field is its generated column, relative to the previous segment on the line. +function decodeFirstVlq(segment: string): number { + let value = 0; + let factor = 1; + let index = 0; + let digit: number; + + do { + digit = BASE64_CHARS.indexOf(segment.charAt(index++)); + value += (digit % 32) * factor; + factor *= 32; + } while (digit >= 32); + + // The lowest bit is the sign. + return value % 2 ? -(value - 1) / 2 : value / 2; +} + +function encodeVlq(value: number): string { + let vlq = value < 0 ? -value * 2 + 1 : value * 2; + let encoded = ''; + + do { + const digit = vlq % 32; + vlq = Math.floor(vlq / 32); + // Sets the continuation bit. + encoded += BASE64_CHARS.charAt(vlq > 0 ? digit + 32 : digit); + } while (vlq > 0); + + return encoded; +} diff --git a/packages/remix/src/v3/emitAssets.ts b/packages/remix/src/v3/emitAssets.ts new file mode 100644 index 000000000000..03239a9c8cc9 --- /dev/null +++ b/packages/remix/src/v3/emitAssets.ts @@ -0,0 +1,90 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { UNSTAMPED_FETCH } from './assetServer'; +import { addDebugIdToSourceMap, findDebugId } from './debugId'; + +/** The subset of `@remix-run/assets`' `AssetServer` this needs. */ +export interface AssetServerLike { + fetch(request: Request): Promise; + getPreloads(filePath: string | readonly string[]): Promise; + [UNSTAMPED_FETCH]?: (request: Request) => Promise; +} + +export interface EmitAssetsOptions { + /** The app's own asset server. Driving the real one is what makes the debug IDs match production. */ + assets: AssetServerLike; + /** Browser entry paths, e.g. `['app/actions/public/entry.ts']`. */ + entries: readonly string[]; + outDir: string; +} + +export interface EmittedAsset { + /** Public URL the module is served from. */ + url: string; + file: string; + /** Written when the asset server produced a source map for the module. */ + mapFile?: string; + debugId?: string; +} + +/** + * Compile a Remix 3 app's browser module graph to disk, for a source map upload. + * + * Remix 3 has no build step, so there is no output directory to upload. This drives the app's own + * asset server instead: same config, so the same compiled output and the same debug IDs the running + * server serves. `getPreloads()` is what forces compilation; the server compiles lazily, so without + * it only the modules something happened to request would exist. + */ +export async function emitAssets(options: EmitAssetsOptions): Promise { + const { assets, entries, outDir } = options; + // Source maps are hidden by default, so they have to be read from the unstamped server. + const fetchMap: AssetServerLike['fetch'] = assets[UNSTAMPED_FETCH] ?? (request => assets.fetch(request)); + + const emitted: EmittedAsset[] = []; + + for (const url of await assets.getPreloads([...entries])) { + const response = await assets.fetch(new Request(`http://asset-server${url}`)); + if (response?.status !== 200) { + continue; + } + + const source = await response.text(); + const debugId = findDebugId(source); + const file = path.join(outDir, toRelativePath(url)); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, source); + + const asset: EmittedAsset = { url, file, debugId }; + + const mapResponse = await fetchMap(new Request(`http://asset-server${toMapUrl(url)}`)); + if (mapResponse?.status === 200) { + const map = await mapResponse.text(); + asset.mapFile = `${file}.map`; + fs.writeFileSync(asset.mapFile, debugId ? addDebugIdToSourceMap(map, debugId) : map); + } + + emitted.push(asset); + } + + return emitted; +} + +// The server resolves the map from the pathname, so `.map` goes before any query. +function toMapUrl(url: string): string { + const queryIndex = url.indexOf('?'); + return queryIndex === -1 ? `${url}.map` : `${url.slice(0, queryIndex)}.map${url.slice(queryIndex)}`; +} + +// Decoded, so the tree on disk mirrors the module layout rather than the asset server's encoding. +// Debug ID matching does not depend on these paths. `..` is dropped so the file stays under `outDir`. +function toRelativePath(url: string): string { + const pathname = url.split('?')[0] ?? url; + return path.join( + ...pathname + .replace(/^\//, '') + .split('/') + .map(segment => decodeURIComponent(segment).replace(/[/\\]/g, '_')) + .filter(segment => segment !== '..'), + ); +} diff --git a/packages/remix/src/v3/index.client.ts b/packages/remix/src/v3/index.client.ts index 7cdc7036be5a..ffe5c04d3175 100644 --- a/packages/remix/src/v3/index.client.ts +++ b/packages/remix/src/v3/index.client.ts @@ -1,8 +1,77 @@ -// A named list rather than `export * from '@sentry/browser'`: Remix 3 has no bundler, so a wildcard -// makes every export reachable and nothing can be tree shaken out of the served module graph. +// A named list rather than `export * from '@sentry/browser'`. // -// `init` is withheld until the Remix 3 browser SDK lands. Re-exporting `@sentry/browser`'s would give -// an app History based tracing, which yields no navigation spans in Remix 3, so it would look -// configured while reporting nothing. -export { captureException, captureMessage } from '@sentry/browser'; +// Remix 3 has no bundler, so this list is what gets tree shaken. A wildcard re-export would reach every +// export of `@sentry/browser`, including Replay, Feedback and the full attribute tables, and none of it +// could be dropped. Add whatever is needed, but keep it a named list. The size limit budget for +// `client-bundle.js` is what enforces that. +// +// Everything comes from `@sentry/browser`, never `@sentry/core`, even where `@sentry/browser` only +// passes a symbol through. The two packages export different `startSpan`, `startInactiveSpan` and +// `startSpanManual`, and only `@sentry/browser`'s installs span streaming on first use. +export { + addBreadcrumb, + addEventProcessor, + addIntegration, + breadcrumbsIntegration, + browserApiErrorsIntegration, + BrowserClient, + captureEvent, + captureException, + captureFeedback, + captureMessage, + captureSession, + close, + continueTrace, + dedupeIntegration, + defaultStackParser, + endSession, + eventFiltersIntegration, + flush, + functionToStringIntegration, + getActiveSpan, + getClient, + getCurrentScope, + getGlobalScope, + getIsolationScope, + getRootSpan, + getTraceData, + globalHandlersIntegration, + httpContextIntegration, + isEnabled, + isInitialized, + lastEventId, + linkedErrorsIntegration, + logger, + makeFetchTransport, + metrics, + parameterize, + Scope, + SDK_VERSION, + setAttribute, + setAttributes, + setContext, + setCurrentClient, + setExtra, + setExtras, + setTag, + setTags, + setUser, + spanToJSON, + spanToTraceHeader, + startInactiveSpan, + startSession, + startSpan, + startSpanManual, + suppressTracing, + updateSpanName, + WINDOW, + withActiveSpan, + withIsolationScope, + withScope, +} from '@sentry/browser'; + export type { BrowserOptions } from '@sentry/browser'; + +export { getDefaultIntegrations, init } from './client/sdk'; +export { browserTracingIntegration } from './client/browserTracingIntegration'; +export { captureRuntimeErrors, instrumentClientRuntime } from './client/errors'; diff --git a/packages/remix/src/v3/index.server.ts b/packages/remix/src/v3/index.server.ts index 870ab16899cb..75b7a65b2cc2 100644 --- a/packages/remix/src/v3/index.server.ts +++ b/packages/remix/src/v3/index.server.ts @@ -4,3 +4,8 @@ export { getDefaultIntegrations, init } from './server/sdk'; export { remixV3Integration } from './server/integration'; export { sentryRemixMiddleware } from './server/middleware'; export { instrumentRemixV3 } from './server/instrument'; +export { instrumentAssetServer } from './assetServer'; +export { emitAssets } from './emitAssets'; +export type { EmitAssetsOptions, EmittedAsset } from './emitAssets'; +export { defaultShouldHandleError } from './server/errorFilter'; +export type { ShouldHandleError } from './server/errorFilter'; diff --git a/packages/remix/src/v3/node.mjs b/packages/remix/src/v3/node.mjs index 59d77f99fd19..df700b229103 100644 --- a/packages/remix/src/v3/node.mjs +++ b/packages/remix/src/v3/node.mjs @@ -1,8 +1,9 @@ // Replaces `--import remix/node-tsx` rather than adding a second flag. // // This has to happen here, not in `Sentry.init()`: the module hook must be in place before -// `@remix-run/fetch-router` is imported, and the subscription before `createRouter()` runs, which is -// while the app's own modules are still being imported. +// `@remix-run/fetch-router` and `@remix-run/assets` are imported, and the subscriptions before +// `createRouter()` and `createAssetServer()` run, which is while the app's own modules are still +// being imported. import { registerDiagnosticsChannelInjection } from '@sentry/server-runtime-injection/register'; registerDiagnosticsChannelInjection(); @@ -10,8 +11,9 @@ registerDiagnosticsChannelInjection(); // A failure to load the SDK must not stop the app: this runs before anything of the app has, and an // uncaught error here means Remix never starts. try { - const { instrumentRemixV3 } = await import('@sentry/remix/v3'); + const { instrumentRemixV3, instrumentAssetServer } = await import('@sentry/remix/v3'); instrumentRemixV3(); + instrumentAssetServer(); } catch (error) { // oxlint-disable-next-line no-console console.warn('[Sentry] Could not load @sentry/remix/v3. The app starts without Sentry instrumentation.', error); diff --git a/packages/remix/src/v3/orchestrionLoader.ts b/packages/remix/src/v3/orchestrionLoader.ts new file mode 100644 index 000000000000..9cf2255fc986 --- /dev/null +++ b/packages/remix/src/v3/orchestrionLoader.ts @@ -0,0 +1,114 @@ +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createLoadHookTransform } from '@sentry/server-utils/orchestrion/load-hook'; +import { remixV3Config } from '@sentry/server-utils/orchestrion/config'; + +/** Node's synchronous `load` hook shape, which `@remix-run/assets` reuses for `scripts.loaders`. */ +export interface ModuleLoadContext { + moduleUrl?: string; + [key: string]: unknown; +} + +export interface ModuleLoadResult { + format: string | null | undefined; + shortCircuit?: boolean; + source?: string | ArrayBuffer | ArrayBufferView; +} + +export type ModuleLoader = ( + url: string, + context: ModuleLoadContext, + nextLoad: (url: string, context?: Partial) => ModuleLoadResult, +) => ModuleLoadResult; + +/** + * An asset server loader that applies orchestrion's transform to browser modules. Remix 3 has no + * bundler, so the asset server's loader chain is the only compile time hook there is. + * + * `dcModuleUrl`, the shim's public URL, must also be in `scripts.external`, so the compiler leaves + * the injected import alone. + */ +export function orchestrionLoader(dcModuleUrl: string): ModuleLoader { + const cached = loaders.get(dcModuleUrl); + if (cached) { + return cached; + } + + // Only the Remix 3 configs. The others target server packages, and their injected snippet imports + // `@sentry/server-utils`, which cannot resolve in a browser. + const transform = createLoadHookTransform({ dcModule: dcModuleUrl, instrumentations: remixV3Config }); + + const loader: ModuleLoader = (url, context, nextLoad) => { + const result = nextLoad(url, context); + if (result.format !== 'module' || typeof result.source !== 'string') { + return result; + } + + // An uninstrumented module is better than one the asset server cannot serve at all. + try { + const transformed = transform(fileURLToPath(url), result.source); + return transformed === undefined ? result : { ...result, source: transformed }; + } catch { + return result; + } + }; + + loaders.set(dcModuleUrl, loader); + return loader; +} + +// One loader per shim URL, so options that pass through `withDebugIdOptions` twice keep one copy. +const loaders = new Map(); + +export function isOrchestrionLoader(loader: unknown): boolean { + for (const known of loaders.values()) { + if (known === loader) { + return true; + } + } + return false; +} + +/** + * Public URL of the shipped shim under the asset server's `node_modules` mount, encoded per segment as + * the asset server encodes imports. `@sentry` and `%40sentry` are different modules to a browser; a + * mismatch loads two shims and instrumentation quietly does nothing. + */ +export function getShimUrl( + basePath: string, + rootDir: string, + mounts: Readonly> | undefined, + shimPath: string, +): string | undefined { + const nodeModulesRoot = path.join(rootDir, 'node_modules'); + const relativePath = path.relative(nodeModulesRoot, shimPath); + // Outside the mount, as with a `link:` install or another Windows drive, the asset server cannot + // serve it. Checked before splitting: a drive prefix is only absolute as part of the whole path. + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + return undefined; + } + const encoded = relativePath + .split(path.sep) + .map(segment => encodeURIComponent(segment)) + .join('/'); + return `${basePath.replace(/\/$/, '')}/${npmMount(mounts)}/${encoded}`; +} + +// The default mounts are `{ app: 'app', npm: 'node_modules' }`, but an app can rename them. +function npmMount(mounts: Readonly> | undefined): string { + const entry = mounts && Object.entries(mounts).find(([, dir]) => dir === 'node_modules' || dir === './node_modules'); + return entry ? entry[0] : 'npm'; +} + +// The shim is built next to this module, so its path follows from this module's own under any +// install layout. A package self reference would not work: the `./v3` subpaths are import only. +export function resolveShimPath(): string { + let here: string; + /*! rollup-include-cjs-only */ + here = __filename; + /*! rollup-include-cjs-only-end */ + /*! rollup-include-esm-only */ + here = fileURLToPath(import.meta.url); + /*! rollup-include-esm-only-end */ + return path.join(path.dirname(here), 'client', 'diagnosticsChannelShim.js'); +} diff --git a/packages/remix/src/v3/server/errorFilter.ts b/packages/remix/src/v3/server/errorFilter.ts new file mode 100644 index 000000000000..9e98a2874a27 --- /dev/null +++ b/packages/remix/src/v3/server/errorFilter.ts @@ -0,0 +1,60 @@ +import { captureException } from '@sentry/core'; + +/** Decides whether a thrown value should become a Sentry issue. */ +export type ShouldHandleError = (error: unknown) => boolean; + +/** + * Skip 3xx and 4xx errors, capture everything else. A numeric `status` in that range is an expected + * outcome, not a fault, and the request still produces a span. + */ +export function defaultShouldHandleError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) { + return true; + } + + const status = (error as { status?: unknown }).status; + + return !(typeof status === 'number' && status >= 300 && status < 500); +} + +// Configured at `Sentry.init()`, but read by subscribers installed earlier by the `--import` entry, so +// it lives in module scope rather than being captured before it exists. +let shouldHandleError: ShouldHandleError = defaultShouldHandleError; + +/** @internal Set by the integration during `Sentry.init()`. */ +export function setShouldHandleError(filter: ShouldHandleError | undefined): void { + shouldHandleError = filter ?? defaultShouldHandleError; +} + +/** + * Report an error unless it is an aborted request or filtered out. + * + * A router failure can arrive twice, from the middleware and again from `onError`. `captureException` + * marks the object `__sentry_captured__` and drops the second report; only a thrown primitive cannot + * be marked and may be reported twice. + */ +export function captureRequestError(error: unknown, request: Request | undefined, mechanism: string): boolean { + // The app's filter can throw. Letting that out would replace the request's own error and skip the + // app's `onError`, so reporting is best effort. + try { + if (request && isRequestAbort(error, request)) { + return false; + } + if (!shouldHandleError(error)) { + return false; + } + captureException(error, { mechanism: { handled: false, type: mechanism } }); + return true; + } catch { + return false; + } +} + +/** + * Whether a rejection is the client giving up rather than the app failing. The router rejects with + * `signal.reason` when the connection drops; without this check every user navigating away mid + * request creates an issue. + */ +export function isRequestAbort(error: unknown, request: Request): boolean { + return request.signal.aborted && error === request.signal.reason; +} diff --git a/packages/remix/src/v3/server/instrument.ts b/packages/remix/src/v3/server/instrument.ts index 8fc254055588..2fee25db560b 100644 --- a/packages/remix/src/v3/server/instrument.ts +++ b/packages/remix/src/v3/server/instrument.ts @@ -1,8 +1,10 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import { createMultiMatcher } from 'remix/route-pattern/match'; +import { consoleSandbox } from '@sentry/core'; import { remixV3Channels } from '@sentry/server-utils/orchestrion/config'; -import type { MatcherLike, RouterOptionsLike } from '../types'; +import type { MatcherLike, RequestListenerOptionsLike, RouterOptionsLike } from '../types'; +import { captureRequestError } from './errorFilter'; import { sentryRemixMiddleware } from './middleware'; const NOOP = (): void => {}; @@ -34,6 +36,11 @@ export function instrumentRemixV3(): void { } subscribed = true; + subscribeToCreateRouter(); + subscribeToCreateRequestListener(); +} + +function subscribeToCreateRouter(): void { diagnosticsChannel.tracingChannel(remixV3Channels.REMIX_V3_CREATE_ROUTER).subscribe({ start(data) { // Node rethrows anything this handler throws as an uncaught exception, which would kill an app @@ -52,32 +59,85 @@ export function instrumentRemixV3(): void { }); } +/** + * Report errors from any fetch handler, router or not. The middleware only sees failures inside a + * router; this is the only hook that covers a plain handler. No abort guard is needed, because the + * listener drops aborted requests before calling `onError`. + */ +function subscribeToCreateRequestListener(): void { + diagnosticsChannel + .tracingChannel(remixV3Channels.REMIX_V3_CREATE_REQUEST_LISTENER) + .subscribe({ + start(data) { + try { + injectOnError(ensureOptions(data.arguments, 1)); + } catch { + // Ignored on purpose. + } + }, + end: NOOP, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + }); +} + +function injectOnError(raw: Record | undefined): void { + if (!raw || markInjected(raw)) { + return; + } + + const options = raw as RequestListenerOptionsLike; + const appOnError = options.onError; + + options.onError = error => { + captureRequestError(error, undefined, 'auto.http.remix_v3.on_error'); + + if (appOnError) { + // Chained so the app keeps its own response. + return appOnError(error); + } + + // Setting `onError` replaced the listener's default handler, which logs the error. Sandboxed so + // the SDK's console instrumentation does not report it a second time. + consoleSandbox(() => { + // oxlint-disable-next-line no-console + console.error(error); + }); + return undefined; + }; +} + /** * The options object, created when the caller omitted it. `undefined` when the caller passed something * that is not an options object, which must not be overwritten. */ -function ensureOptions(args: unknown[]): Record | undefined { - const existing = args[0]; +function ensureOptions(args: unknown[], index = 0): Record | undefined { + const existing = args[index]; if (existing === undefined || existing === null) { const created: Record = {}; - args[0] = created; + args[index] = created; return created; } return typeof existing === 'object' ? (existing as Record) : undefined; } -function injectRouterMiddleware(raw: Record | undefined): void { - if (!raw) { - return; - } - +/** Whether this object was already injected into, marking it when it was not. */ +function markInjected(raw: Record): boolean { const marker = raw as { [INJECTED]?: boolean }; if (marker[INJECTED]) { - return; + return true; } marker[INJECTED] = true; + return false; +} + +function injectRouterMiddleware(raw: Record | undefined): void { + if (!raw || markInjected(raw)) { + return; + } const options = raw as RouterOptionsLike; diff --git a/packages/remix/src/v3/server/integration.ts b/packages/remix/src/v3/server/integration.ts index 84af41b67156..3ceabf6acbd4 100644 --- a/packages/remix/src/v3/server/integration.ts +++ b/packages/remix/src/v3/server/integration.ts @@ -1,13 +1,24 @@ import { defineIntegration, type IntegrationFn } from '@sentry/core'; +import { setShouldHandleError, type ShouldHandleError } from './errorFilter'; import { instrumentRemixV3 } from './instrument'; const INTEGRATION_NAME = 'RemixV3' as const; -const _remixV3Integration = (() => { +interface RemixV3IntegrationOptions { + /** + * Decides which thrown values become issues. Return `false` to drop one. The default skips a numeric + * `status` from 300 to 499. Aborted requests never reach this. + */ + shouldHandleError?: ShouldHandleError; +} + +const _remixV3Integration = ((options: RemixV3IntegrationOptions = {}) => { return { name: INTEGRATION_NAME, setupOnce() { + setShouldHandleError(options.shouldHandleError); + // Usually a no-op: `createRouter()` runs while the app's modules are imported, before any // `init()`, so `--import @sentry/remix/v3/node` has already subscribed. This covers setups that // register the module hook from `init()` instead. @@ -17,6 +28,7 @@ const _remixV3Integration = (() => { }) satisfies IntegrationFn; /** - * Names the `http.server` spans `@sentry/node` opens after the matched Remix 3 route. + * Names the `http.server` spans `@sentry/node` opens after the matched Remix 3 route, and reports the + * errors the spans alone would not show. */ export const remixV3Integration = defineIntegration(_remixV3Integration); diff --git a/packages/remix/src/v3/server/middleware.ts b/packages/remix/src/v3/server/middleware.ts index 0409d48f992e..541adc696214 100644 --- a/packages/remix/src/v3/server/middleware.ts +++ b/packages/remix/src/v3/server/middleware.ts @@ -11,6 +11,7 @@ import { } from '@sentry/core'; import type { MatcherLike, MiddlewareLike, NextFunctionLike, RequestContextLike } from '../types'; +import { captureRequestError } from './errorFilter'; import { resolveRoutePattern } from './route'; /** @@ -28,7 +29,16 @@ export function sentryRemixMiddleware(matcher: MatcherLike): MiddlewareLike { // Applied before `next()` so anything captured while the handler runs already carries the route. applyRoute(isolationScope, matcher, context); - const response = await next(); + let response; + try { + response = await next(); + } catch (error) { + // Captured here as well as in `onError`, so the event carries the route this middleware just put + // on the scope. + captureRequestError(error, context.request, 'auto.http.remix_v3.middleware'); + throw error; + } + setResponseStatus(response); return response; diff --git a/packages/remix/src/v3/types.ts b/packages/remix/src/v3/types.ts index 87dff35d797c..a5bcbe96e017 100644 --- a/packages/remix/src/v3/types.ts +++ b/packages/remix/src/v3/types.ts @@ -37,3 +37,8 @@ export interface RouterOptionsLike { middleware?: MiddlewareLike[]; matcher?: MatcherLike; } + +/** `createRequestListener`'s options, of which only the error hook is touched. */ +export interface RequestListenerOptionsLike { + onError?: (error: unknown) => void | Response | Promise; +} diff --git a/packages/remix/test/client/routeProvider.test.ts b/packages/remix/test/client/routeProvider.test.ts new file mode 100644 index 000000000000..d92edc120cba --- /dev/null +++ b/packages/remix/test/client/routeProvider.test.ts @@ -0,0 +1,50 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createRemixRouteProvider } from '../../src/client/routeProvider'; + +const globalWithInjectedManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryRemixRouteManifest: string | undefined; +}; + +const MANIFEST = JSON.stringify({ + staticRoutes: [{ path: '/about' }], + dynamicRoutes: [{ path: '/users/:id', regex: '^/users/([^/]+)$', paramNames: ['id'] }], +}); + +let originalDocument: unknown; + +describe('createRemixRouteProvider', () => { + beforeEach(() => { + globalWithInjectedManifest._sentryRemixRouteManifest = MANIFEST; + originalDocument = (GLOBAL_OBJ as { document?: unknown }).document; + // `resolveCurrentRoute` reads `document.location.href`. + (GLOBAL_OBJ as { document?: unknown }).document = { location: { href: 'https://example.com/users/42' } }; + }); + + afterEach(() => { + globalWithInjectedManifest._sentryRemixRouteManifest = undefined; + (GLOBAL_OBJ as { document?: unknown }).document = originalDocument; + }); + + it('parameterizes a URL from the build-time manifest', () => { + expect(createRemixRouteProvider().resolveRoute(new URL('https://example.com/users/42'))).toBe('/users/:id'); + }); + + it('resolves a static route', () => { + expect(createRemixRouteProvider().resolveRoute(new URL('https://example.com/about'))).toBe('/about'); + }); + + it('resolves the current route from the document location', () => { + expect(createRemixRouteProvider().resolveCurrentRoute()).toBe('/users/:id'); + }); + + it('returns undefined for a URL the manifest does not know', () => { + expect(createRemixRouteProvider().resolveRoute(new URL('https://example.com/nope/deep'))).toBeUndefined(); + }); + + it('returns undefined when the manifest was never injected', () => { + globalWithInjectedManifest._sentryRemixRouteManifest = undefined; + + expect(createRemixRouteProvider().resolveRoute(new URL('https://example.com/users/42'))).toBeUndefined(); + }); +}); diff --git a/packages/remix/test/index.client.test.ts b/packages/remix/test/index.client.test.ts index 5b39e7736e29..1e0e26343494 100644 --- a/packages/remix/test/index.client.test.ts +++ b/packages/remix/test/index.client.test.ts @@ -43,6 +43,17 @@ describe('Client init()', () => { ); }); + it('passes the Remix route provider unless the user passed one', () => { + init({}); + expect(reactInit).toHaveBeenLastCalledWith( + expect.objectContaining({ routeProvider: expect.objectContaining({ resolveRoute: expect.any(Function) }) }), + ); + + const routeProvider = { resolveRoute: () => '/custom', resolveCurrentRoute: () => '/custom' }; + init({ routeProvider }); + expect(reactInit).toHaveBeenLastCalledWith(expect.objectContaining({ routeProvider })); + }); + it('returns client from init', () => { expect(init({})).not.toBeUndefined(); }); diff --git a/packages/remix/test/v3-exports.test.ts b/packages/remix/test/v3-exports.test.ts index 96e2c033ae76..a549611f10b8 100644 --- a/packages/remix/test/v3-exports.test.ts +++ b/packages/remix/test/v3-exports.test.ts @@ -63,4 +63,13 @@ describe('Remix 3 exports', () => { expect(packageJson.peerDependenciesMeta, `${name} should be optional`).toHaveProperty([name, 'optional'], true); } }); + + it('ships the browser channel shim with the default export the transform imports', () => { + // Only the explicit rollup entry keeps the default export the injected import needs. Losing it + // fails silently: the build stays green and the browser gets no instrumentation. + const shim = fs.readFileSync(path.join(packageRoot, 'build/esm/v3/client/diagnosticsChannelShim.js'), 'utf8'); + + // Either spelling rollup may pick for a default export. + expect(shim).toMatch(/^export (?:default |\{[^}]*\bas default\b)/m); + }); }); diff --git a/packages/remix/test/v3/assetServer.test.ts b/packages/remix/test/v3/assetServer.test.ts new file mode 100644 index 000000000000..f7f7aa7eda36 --- /dev/null +++ b/packages/remix/test/v3/assetServer.test.ts @@ -0,0 +1,311 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import { remixV3Channels } from '@sentry/server-utils/orchestrion/config'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import * as path from 'node:path'; +import { debugIdLoader, instrumentAssetServer } from '../../src/v3/assetServer'; +import type * as OrchestrionLoader from '../../src/v3/orchestrionLoader'; + +// In the repo the shim sits in `src/`, which no asset server mount could serve. Place it where an +// installed copy would be, so the URL logic runs the way it does in an app. +vi.mock('../../src/v3/orchestrionLoader', async importOriginal => ({ + ...(await importOriginal()), + resolveShimPath: () => + path.join( + process.cwd(), + 'node_modules', + '@sentry', + 'remix', + 'build', + 'esm', + 'v3', + 'client', + 'diagnosticsChannelShim.js', + ), +})); +import { getDebugId, getDebugIdSnippet } from '../../src/v3/debugId'; + +type Options = Record; +type FakeFetch = (request: Request) => Promise; + +const channel = diagnosticsChannel.tracingChannel(remixV3Channels.REMIX_V3_CREATE_ASSET_SERVER); + +// Stands in for orchestrion's transform, which calls the original with the channel context's +// `arguments`, so subscribers can replace them. +function createAssetServer(options: Options, fetch: FakeFetch = async () => null) { + const context = { arguments: [options] as unknown[] }; + let receivedOptions: Options | undefined; + + const server = channel.traceSync(() => { + receivedOptions = context.arguments[0] as Options; + return { fetch }; + }, context); + + return { server, receivedOptions: receivedOptions as Options }; +} + +function javascript(body: string): Response { + return new Response(body, { headers: { 'content-type': 'application/javascript; charset=utf-8', etag: 'W/"a"' } }); +} + +describe('instrumentAssetServer', () => { + beforeAll(() => { + instrumentAssetServer(); + }); + + describe('options', () => { + it('generates source maps when the app did not configure them', () => { + const { receivedOptions } = createAssetServer({ basePath: '/assets' }); + + expect(receivedOptions.sourceMaps).toBe('external'); + }); + + it('keeps source maps the app configured', () => { + const { receivedOptions } = createAssetServer({ basePath: '/assets', sourceMaps: 'inline' }); + + expect(receivedOptions.sourceMaps).toBe('inline'); + }); + + it('keeps source maps off when the app disabled them, and warns', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const { receivedOptions } = createAssetServer({ basePath: '/assets', sourceMaps: false }); + + expect(receivedOptions.sourceMaps).toBe(false); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Source maps are disabled in your asset server')); + warn.mockRestore(); + }); + + it('adds the transform and then the debug ID loader after the app loaders', () => { + const appLoader = vi.fn(); + const { receivedOptions } = createAssetServer({ + allowPackages: ['remix'], + scripts: { loaders: [appLoader], define: { a: 'b' } }, + }); + + expect(receivedOptions.scripts).toEqual({ + loaders: [appLoader, expect.any(Function), debugIdLoader], + define: { a: 'b' }, + external: [expect.stringContaining('/client/diagnosticsChannelShim.js')], + }); + }); + + it('points the transform at the shim by an encoded public URL under the node_modules mount', () => { + const { receivedOptions } = createAssetServer({ + allowPackages: ['remix'], + basePath: '/assets', + mounts: { app: 'app', deps: 'node_modules' }, + }); + + const [shimUrl] = receivedOptions.scripts.external; + expect(shimUrl).toMatch(/^\/assets\/deps\//); + // `@` must arrive as `%40`: the browser treats the two spellings as different modules. + expect(shimUrl).not.toContain('@'); + }); + + it('does not mutate the options the app passed', () => { + const options = { basePath: '/assets' }; + createAssetServer(options); + + expect(options).toEqual({ basePath: '/assets' }); + }); + + it('serves modules uninstrumented, with a warning, when the shim is outside the node_modules mount', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + // A rootDir the shim cannot be under, as with a `link:` install. + const { receivedOptions } = createAssetServer({ + allowPackages: ['remix'], + basePath: '/assets', + rootDir: '/nowhere/near', + }); + + expect(receivedOptions.scripts.loaders).toEqual([debugIdLoader]); + expect(receivedOptions.scripts.external).toEqual([]); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('outside the asset server')); + warn.mockRestore(); + }); + + it('allows the SDK package, so the injected shim import can be served', () => { + const { receivedOptions } = createAssetServer({ allowPackages: ['remix'] }); + + expect(receivedOptions.allowPackages).toEqual(['remix', '@sentry/remix']); + }); + + it('injects nothing when the app serves no packages, since no module could import the shim', () => { + const { receivedOptions } = createAssetServer({ basePath: '/assets' }); + + expect(receivedOptions.allowPackages).toBeUndefined(); + expect(receivedOptions.scripts.loaders).toEqual([debugIdLoader]); + expect(receivedOptions.scripts.external).toEqual([]); + }); + + it('adds each loader once when the same options are reused', () => { + const first = createAssetServer({ allowPackages: ['remix'] }).receivedOptions; + const { receivedOptions } = createAssetServer(first); + + expect(receivedOptions.scripts.loaders).toEqual([expect.any(Function), debugIdLoader]); + expect(receivedOptions.scripts.external).toHaveLength(1); + }); + }); + + describe('debugIdLoader', () => { + it('injects a snippet whose ID hashes the module URL and its compiled source', () => { + const result = debugIdLoader('file:///app/entry.ts', { moduleUrl: '/assets/app/entry.ts' }, () => ({ + format: 'module', + source: 'export const a = 1;', + })); + + const debugId = getDebugId('/assets/app/entry.ts\nexport const a = 1;'); + expect(result.source).toBe(`${getDebugIdSnippet(debugId)}\nexport const a = 1;`); + }); + + it('gives identical modules at different URLs different IDs', () => { + const load = () => ({ format: 'module', source: 'export const a = 1;' }); + + const first = debugIdLoader('file:///app/a.ts', { moduleUrl: '/assets/app/a.ts' }, load); + const second = debugIdLoader('file:///app/b.ts', { moduleUrl: '/assets/app/b.ts' }, load); + + expect(first.source).not.toBe(second.source); + }); + }); + + describe('served assets', () => { + const debugId = getDebugId('module'); + const moduleCode = `${getDebugIdSnippet(debugId)}export const a=1;\n//# sourceMappingURL=/assets/app/entry.ts.map`; + + function createServer(options: Options = { sourceMaps: 'external' }) { + const fetch = vi.fn(async request => { + const { pathname } = new URL(request.url); + if (pathname === '/assets/app/entry.ts') { + return javascript(moduleCode); + } + if (pathname === '/assets/app/entry.ts.map') { + return new Response('{"version":3,"mappings":"AAAA"}', { headers: { 'content-type': 'application/json' } }); + } + if (pathname === '/assets/app/plain.js') { + return javascript('export const b=1;'); + } + return null; + }); + + return { server: createAssetServer(options, fetch).server, fetch }; + } + + it('appends the debugId comment to modules', async () => { + const { server } = createServer(); + + const response = await server.fetch(new Request('http://localhost/assets/app/entry.ts')); + + expect(await response?.text()).toBe(`${moduleCode}\n//# debugId=${debugId}`); + expect(response?.headers.get('etag')).toBe('W/"a"'); + }); + + it('adds the debug ID of the module to its source map', async () => { + const { server } = createServer(); + + const response = await server.fetch(new Request('http://localhost/assets/app/entry.ts.map')); + + expect(await response?.json()).toEqual({ version: 3, mappings: 'AAAA', debugId, debug_id: debugId }); + }); + + describe('when the app did not configure source maps', () => { + it('does not reference the source map from modules', async () => { + const { server } = createServer({}); + + const response = await server.fetch(new Request('http://localhost/assets/app/entry.ts')); + + expect(await response?.text()).toBe(`${getDebugIdSnippet(debugId)}export const a=1;\n//# debugId=${debugId}`); + }); + + it('does not serve source maps', async () => { + const { server } = createServer({}); + + expect(await server.fetch(new Request('http://localhost/assets/app/entry.ts.map'))).toBeNull(); + }); + }); + + describe('memoizes by ETag', () => { + // The asset server serves the same compiled module under the same ETag until the file changes, + // so the stamped body is computed once per version, not copied on every hit. + function createRecordingServer(bodies: string[] = [moduleCode, moduleCode]) { + const served: Response[] = []; + let n = 0; + const fetch: FakeFetch = async request => { + const { pathname } = new URL(request.url); + if (pathname === '/assets/app/entry.ts') { + const body = bodies[Math.min(n++, bodies.length - 1)] as string; + // As the real server does: a new body gets a new ETag, the same body keeps its ETag. + const response = new Response(body, { + headers: { 'content-type': 'application/javascript', etag: `W/"${bodies.indexOf(body)}"` }, + }); + served.push(response); + return response; + } + if (pathname === '/assets/app/entry.ts.map') { + return new Response('{"version":3,"mappings":"AAAA"}', { + headers: { 'content-type': 'application/json', etag: 'W/"m"' }, + }); + } + return null; + }; + const fetchSpy = vi.fn(fetch); + return { server: createAssetServer({ sourceMaps: 'external' }, fetchSpy).server, served, fetchSpy }; + } + + it('reads a module body once per ETag', async () => { + const { server, served } = createRecordingServer(); + + const first = await (await server.fetch(new Request('http://localhost/assets/app/entry.ts')))?.text(); + const second = await (await server.fetch(new Request('http://localhost/assets/app/entry.ts')))?.text(); + + expect(second).toBe(first); + expect(served.map(response => response.bodyUsed)).toEqual([true, false]); + }); + + it('reads the module once for a source map served under the same ETag', async () => { + const { server, fetchSpy } = createRecordingServer(); + + await server.fetch(new Request('http://localhost/assets/app/entry.ts.map')); + await server.fetch(new Request('http://localhost/assets/app/entry.ts.map')); + + // Two map requests, and the module looked up only for the first. + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it('stamps a changed module again', async () => { + const changedId = getDebugId('changed'); + const changed = `${getDebugIdSnippet(changedId)}export const a=2;`; + const { server, served } = createRecordingServer([moduleCode, changed]); + + await server.fetch(new Request('http://localhost/assets/app/entry.ts')); + const second = await server.fetch(new Request('http://localhost/assets/app/entry.ts')); + + expect(await second?.text()).toBe(`${changed}\n//# debugId=${changedId}`); + expect(served.map(response => response.bodyUsed)).toEqual([true, true]); + }); + }); + + it('leaves modules without a snippet unchanged', async () => { + const { server } = createServer(); + + const response = await server.fetch(new Request('http://localhost/assets/app/plain.js')); + + expect(await response?.text()).toBe('export const b=1;'); + }); + + it('passes through requests the asset server does not handle', async () => { + const { server } = createServer(); + + expect(await server.fetch(new Request('http://localhost/other'))).toBeNull(); + }); + + it('leaves HEAD requests alone', async () => { + const { server, fetch } = createServer(); + const request = new Request('http://localhost/assets/app/entry.ts', { method: 'HEAD' }); + + await server.fetch(request); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith(request); + }); + }); +}); diff --git a/packages/remix/test/v3/browserTracingIntegration.test.ts b/packages/remix/test/v3/browserTracingIntegration.test.ts new file mode 100644 index 000000000000..2a78829b1107 --- /dev/null +++ b/packages/remix/test/v3/browserTracingIntegration.test.ts @@ -0,0 +1,119 @@ +import type { Client } from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const startBrowserTracingNavigationSpan = vi.fn(); +const upstreamIntegration = { name: 'BrowserTracing', afterAllSetup: vi.fn() }; +const upstreamOptions: { instrumentNavigation?: boolean }[] = []; + +vi.mock('@sentry/browser', () => ({ + browserTracingIntegration: (options: { instrumentNavigation?: boolean }) => { + upstreamOptions.push(options); + return upstreamIntegration; + }, + startBrowserTracingNavigationSpan: (...args: unknown[]) => startBrowserTracingNavigationSpan(...args), + WINDOW: globalThis, +})); + +const { browserTracingIntegration } = await import('../../src/v3/client/browserTracingIntegration'); + +interface FakeNavigateEvent { + canIntercept: boolean; + info?: unknown; + destination?: { url: string }; +} + +const client = { getOptions: () => ({ traceLifecycle: 'stream' }) } as unknown as Client; + +/** Sets up the integration over a fake Navigation API and returns a way to fire `navigate` events. */ +function setup(options = {}): (event: FakeNavigateEvent) => void { + let listener: ((event: FakeNavigateEvent) => void) | undefined; + + Object.assign(globalThis, { + location: { origin: 'https://app.test', pathname: '/' }, + navigation: { + addEventListener: (_type: string, fn: (event: FakeNavigateEvent) => void) => { + listener = fn; + }, + }, + }); + + browserTracingIntegration(options).afterAllSetup?.(client); + + return event => listener?.(event); +} + +describe('browserTracingIntegration', () => { + beforeEach(() => { + startBrowserTracingNavigationSpan.mockClear(); + upstreamOptions.length = 0; + }); + + it('leaves page loads to the upstream integration and takes over navigation', () => { + setup(); + + expect(upstreamOptions[0]).toEqual({ instrumentNavigation: false }); + }); + + it('starts a navigation span for an intercepted navigation', () => { + const navigate = setup(); + + navigate({ canIntercept: true, destination: { url: 'https://app.test/users/12345' } }); + + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledWith( + client, + { + // Low cardinality, because the browser has no route patterns. + name: 'Navigation', + attributes: { + 'sentry.segment.name.source': 'url', + 'sentry.origin': 'auto.navigation.remix_v3', + }, + }, + { url: 'https://app.test/users/12345' }, + ); + }); + + // Each of these becomes a new document load, which the upstream integration reports as a page load, + // so a navigation span would count the same action twice. + it.each([ + ['the runtime cannot intercept it', { canIntercept: false, destination: { url: 'https://app.test/x' } }], + [ + "it is the runtime's own document reload", + { canIntercept: true, info: 'remix-document-reload', destination: { url: 'https://app.test/x' } }, + ], + ['it leaves the origin', { canIntercept: true, destination: { url: 'https://elsewhere.test/x' } }], + ['there is no destination', { canIntercept: true }], + ])('starts no navigation span when %s', (_why, event) => { + const navigate = setup(); + + navigate(event as FakeNavigateEvent); + + expect(startBrowserTracingNavigationSpan).not.toHaveBeenCalled(); + }); + + it('names the span after the path when span streaming is off', () => { + const staticClient = { getOptions: () => ({ traceLifecycle: 'static' }) } as unknown as Client; + let listener: ((event: FakeNavigateEvent) => void) | undefined; + Object.assign(globalThis, { + location: { origin: 'https://app.test', pathname: '/' }, + navigation: { addEventListener: (_t: string, fn: typeof listener) => void (listener = fn) }, + }); + browserTracingIntegration().afterAllSetup?.(staticClient); + + listener?.({ canIntercept: true, destination: { url: 'https://app.test/users/12345?q=1' } }); + + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledWith( + staticClient, + expect.objectContaining({ name: '/users/12345' }), + { url: 'https://app.test/users/12345?q=1' }, + ); + }); + + it('starts no navigation span when instrumentNavigation is off', () => { + const navigate = setup({ instrumentNavigation: false }); + + navigate({ canIntercept: true, destination: { url: 'https://app.test/users/1' } }); + + expect(startBrowserTracingNavigationSpan).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/remix/test/v3/debugId.test.ts b/packages/remix/test/v3/debugId.test.ts new file mode 100644 index 000000000000..6c813590b835 --- /dev/null +++ b/packages/remix/test/v3/debugId.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { addDebugIdToSourceMap, findDebugId, getDebugId, injectDebugIdSnippet } from '../../src/v3/debugId'; + +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +function withInlineSourceMap(code: string, map: object): string { + return `${code}\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(map)).toString('base64')}`; +} + +function readInlineSourceMap(source: string): Record { + const encoded = source.match(/sourceMappingURL=data:application\/json;base64,(.+)$/)?.[1] ?? ''; + return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')); +} + +describe('getDebugId', () => { + it('returns a UUID v4 shaped ID', () => { + expect(getDebugId('export const a = 1;')).toMatch(UUID_REGEX); + }); + + it('is derived from the source only', () => { + expect(getDebugId('export const a = 1;')).toBe(getDebugId('export const a = 1;')); + expect(getDebugId('export const a = 1;')).not.toBe(getDebugId('export const a = 2;')); + }); + + it('matches the ID the bundler plugins derive from the same input', () => { + // `stringToUUID('')` in `@sentry/bundler-plugins/core`. + expect(getDebugId('')).toBe('e3b0c442-98fc-4c14-9afb-f4c8996fb924'); + }); +}); + +describe('injectDebugIdSnippet', () => { + const debugId = getDebugId('module'); + + it('puts the snippet on the first line', () => { + const output = injectDebugIdSnippet('export const a = 1;', debugId); + + expect(output.split('\n')).toEqual([expect.stringContaining(`sentry-dbid-${debugId}`), 'export const a = 1;']); + }); + + it('registers the ID in `_sentryDebugIds` keyed by the stack', () => { + const globalObject = globalThis as { _sentryDebugIds?: Record }; + delete globalObject._sentryDebugIds; + + // oxlint-disable-next-line typescript/no-implied-eval + new Function(injectDebugIdSnippet('', debugId))(); + + expect(Object.values(globalObject._sentryDebugIds ?? {})).toEqual([debugId]); + delete globalObject._sentryDebugIds; + }); + + it('returns a source map that shifts the incoming positions down one line', () => { + const input = withInlineSourceMap('import a from "a";\nexport const b = a;', { + version: 3, + sources: ['/assets/app/entry.ts'], + names: [], + // Line 0 has segments at columns 0 and 4, line 1 at columns 0 and 6. + mappings: 'AAAA,IAAI;AACA,MAAM', + }); + + const output = injectDebugIdSnippet(input, debugId); + + expect(output.split('\n').slice(1, 3)).toEqual(['import a from "a";', 'export const b = a;']); + expect(readInlineSourceMap(output)).toEqual({ + version: 3, + sources: ['/assets/app/entry.ts'], + names: [], + // Identity at the same columns, one line lower. The first segment of the second line steps + // back from column 4 to 0, hence `J` (-4). + mappings: ';AAAA,IAAI;AACJ,MAAM', + }); + }); +}); + +describe('findDebugId', () => { + it('reads the ID back out of minified code', () => { + const debugId = getDebugId('module'); + const minified = injectDebugIdSnippet('', debugId).replace(/"/g, '`'); + + expect(findDebugId(minified)).toBe(debugId); + }); + + it('returns undefined for code without a snippet', () => { + expect(findDebugId('export const a = 1;')).toBeUndefined(); + }); +}); + +describe('addDebugIdToSourceMap', () => { + it('sets both field names and keeps the rest of the map', () => { + const map = JSON.parse(addDebugIdToSourceMap('{"version":3,"mappings":"AAAA"}', 'abc')); + + expect(map).toEqual({ version: 3, mappings: 'AAAA', debugId: 'abc', debug_id: 'abc' }); + }); +}); diff --git a/packages/remix/test/v3/diagnosticsChannelShim.test.ts b/packages/remix/test/v3/diagnosticsChannelShim.test.ts new file mode 100644 index 000000000000..73b305197b1a --- /dev/null +++ b/packages/remix/test/v3/diagnosticsChannelShim.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, vi } from 'vitest'; + +describe('diagnosticsChannelShim', () => { + it('shares one registry between two loaded copies of the module', async () => { + const first = await import('../../src/v3/client/diagnosticsChannelShim'); + const seen: unknown[] = []; + first.tracingChannel('orchestrion:test:run').subscribe({ end: context => void seen.push(context) }); + + // A second module instance, as a browser gets when the same file is loaded under two URLs. + vi.resetModules(); + const second = await import('../../src/v3/client/diagnosticsChannelShim'); + expect(second.tracingChannel).not.toBe(first.tracingChannel); + + second.tracingChannel('orchestrion:test:run').end.publish({ result: 1 }); + + expect(seen).toEqual([{ result: 1 }]); + }); +}); diff --git a/packages/remix/test/v3/emitAssets.test.ts b/packages/remix/test/v3/emitAssets.test.ts new file mode 100644 index 000000000000..451b71a3f94d --- /dev/null +++ b/packages/remix/test/v3/emitAssets.test.ts @@ -0,0 +1,121 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { UNSTAMPED_FETCH } from '../../src/v3/assetServer'; +import { getDebugId, getDebugIdSnippet } from '../../src/v3/debugId'; +import { emitAssets } from '../../src/v3/emitAssets'; + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function outDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'emit-assets-')); + dirs.push(dir); + return dir; +} + +/** What an instrumented asset server looks like: stamped modules, maps only through the unstamped fetch. */ +function fakeServer(modules: Record) { + const stamped = async (request: Request): Promise => { + const { pathname } = new URL(request.url); + const source = modules[pathname]; + if (source === undefined) { + return null; + } + const id = getDebugId(source); + return new Response(`${getDebugIdSnippet(id)}\n${source}\n//# debugId=${id}`, { + headers: { 'content-type': 'application/javascript' }, + }); + }; + const unstamped = async (request: Request): Promise => { + const { pathname } = new URL(request.url); + if (!pathname.endsWith('.map') || modules[pathname.slice(0, -4)] === undefined) { + return null; + } + return new Response('{"version":3,"mappings":"AAAA"}', { headers: { 'content-type': 'application/json' } }); + }; + return { + fetch: stamped, + [UNSTAMPED_FETCH]: unstamped, + getPreloads: async () => Object.keys(modules), + }; +} + +describe('emitAssets', () => { + it('writes every module in the graph with a map, and both carry the same debug ID', async () => { + const dir = outDir(); + const assets = fakeServer({ + '/assets/app/entry.ts': 'export const a = 1;', + '/assets/npm/%40remix-run/ui/run.js': 'export const b = 2;', + }); + + const emitted = await emitAssets({ assets, entries: ['app/entry.ts'], outDir: dir }); + + expect(emitted).toHaveLength(2); + for (const asset of emitted) { + expect(asset.debugId).toBeDefined(); + expect(asset.mapFile).toBeDefined(); + expect(fs.readFileSync(asset.file, 'utf8')).toContain(`//# debugId=${asset.debugId}`); + expect(JSON.parse(fs.readFileSync(asset.mapFile as string, 'utf8'))).toMatchObject({ debugId: asset.debugId }); + } + // Decoded on disk, so the tree mirrors the module layout. + expect(emitted[1]?.file).toBe(path.join(dir, 'assets', 'npm', '@remix-run', 'ui', 'run.js')); + }); + + it('reads source maps through the unstamped fetch, since stamping hides them', async () => { + const assets = fakeServer({ '/assets/app/entry.ts': 'export const a = 1;' }); + // What the stamped server answers for a hidden map. + expect(await assets.fetch(new Request('http://x/assets/app/entry.ts.map'))).toBeNull(); + + const [asset] = await emitAssets({ assets, entries: ['app/entry.ts'], outDir: outDir() }); + + expect(asset?.mapFile).toBeDefined(); + }); + + it('asks for the map on the pathname when the URL carries a query', async () => { + const urls: string[] = []; + const assets = { + fetch: async (request: Request) => { + urls.push(request.url); + return request.url.includes('.map') ? new Response('{"version":3}') : new Response('export const a = 1;'); + }, + getPreloads: async () => ['/assets/app/entry.ts?transform=x'], + }; + + const [asset] = await emitAssets({ assets, entries: ['app/entry.ts'], outDir: outDir() }); + + expect(urls).toContain('http://asset-server/assets/app/entry.ts.map?transform=x'); + expect(asset?.mapFile).toBeDefined(); + }); + + it('keeps every file under outDir, whatever the URL looks like', async () => { + const dir = outDir(); + const assets = { + fetch: async (request: Request) => (request.url.endsWith('.map') ? null : new Response('export const a = 1;')), + getPreloads: async () => ['/assets/%2e%2e/%2e%2e/escaped.ts'], + }; + + const [asset] = await emitAssets({ assets, entries: ['escaped.ts'], outDir: dir }); + + expect(asset?.file).toBe(path.join(dir, 'assets', 'escaped.ts')); + }); + + it('emits a module without a debug ID as served, so the caller can tell', async () => { + const assets = { + fetch: async (request: Request) => + new URL(request.url).pathname.endsWith('.map') ? null : new Response('export const a = 1;'), + getPreloads: async () => ['/assets/app/entry.ts'], + }; + + const [asset] = await emitAssets({ assets, entries: ['app/entry.ts'], outDir: outDir() }); + + expect(asset?.debugId).toBeUndefined(); + expect(asset?.mapFile).toBeUndefined(); + }); +}); diff --git a/packages/remix/test/v3/errorFilter.test.ts b/packages/remix/test/v3/errorFilter.test.ts new file mode 100644 index 000000000000..f307f8bdd4e4 --- /dev/null +++ b/packages/remix/test/v3/errorFilter.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const captureException = vi.fn(); +vi.mock('@sentry/core', () => ({ captureException: (...args: unknown[]) => captureException(...args) })); + +const { captureRequestError, defaultShouldHandleError, isRequestAbort, setShouldHandleError } = + await import('../../src/v3/server/errorFilter'); + +/** A request whose connection has dropped, rejecting with `signal.reason` as the router does. */ +function abortedRequest(reason: unknown): Request { + const controller = new AbortController(); + const request = new Request('http://x/', { signal: controller.signal }); + controller.abort(reason); + return request; +} + +describe('defaultShouldHandleError', () => { + it.each([300, 302, 404, 499])('skips status %i', status => { + expect(defaultShouldHandleError({ status })).toBe(false); + }); + + it.each([200, 500, 503])('captures status %i', status => { + expect(defaultShouldHandleError({ status })).toBe(true); + }); + + it.each([ + ['a plain error', new Error('boom')], + ['a thrown string', 'boom'], + ['null', null], + ['a non-numeric status', { status: '404' }], + ])('captures %s', (_what, error) => { + expect(defaultShouldHandleError(error)).toBe(true); + }); +}); + +describe('isRequestAbort', () => { + it('matches the reason the request was aborted with', () => { + const reason = new Error('aborted'); + expect(isRequestAbort(reason, abortedRequest(reason))).toBe(true); + }); + + it('does not match a different error on an aborted request', () => { + expect(isRequestAbort(new Error('boom'), abortedRequest(new Error('aborted')))).toBe(false); + }); + + it('does not match on a live request', () => { + expect(isRequestAbort(new Error('boom'), new Request('http://x/'))).toBe(false); + }); +}); + +describe('captureRequestError', () => { + beforeEach(() => { + captureException.mockClear(); + setShouldHandleError(undefined); + }); + + it('captures with the mechanism it was given', () => { + const error = new Error('boom'); + + expect(captureRequestError(error, new Request('http://x/'), 'auto.test')).toBe(true); + expect(captureException).toHaveBeenCalledWith(error, { mechanism: { handled: false, type: 'auto.test' } }); + }); + + it('drops an aborted request', () => { + const reason = new Error('aborted'); + + expect(captureRequestError(reason, abortedRequest(reason), 'auto.test')).toBe(false); + expect(captureException).not.toHaveBeenCalled(); + }); + + it('drops what the filter rejects', () => { + setShouldHandleError(() => false); + + expect(captureRequestError(new Error('boom'), new Request('http://x/'), 'auto.test')).toBe(false); + expect(captureException).not.toHaveBeenCalled(); + }); + + it('swallows a throwing filter so the request keeps its own error', () => { + setShouldHandleError(() => { + throw new Error('filter broke'); + }); + + expect(() => captureRequestError(new Error('boom'), new Request('http://x/'), 'auto.test')).not.toThrow(); + expect(captureException).not.toHaveBeenCalled(); + }); + + it('still captures without a request, as the onError hook has none', () => { + expect(captureRequestError(new Error('boom'), undefined, 'auto.test')).toBe(true); + expect(captureException).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/remix/test/v3/errors.test.ts b/packages/remix/test/v3/errors.test.ts new file mode 100644 index 000000000000..c5224d7fd77e --- /dev/null +++ b/packages/remix/test/v3/errors.test.ts @@ -0,0 +1,63 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest'; + +const captureException = vi.fn(); +vi.mock('@sentry/browser', () => ({ captureException: (...args: unknown[]) => captureException(...args) })); + +const { captureRuntimeErrors, instrumentClientRuntime } = await import('../../src/v3/client/errors'); +const { tracingChannel } = await import('../../src/v3/client/diagnosticsChannelShim'); + +/** Stands in for the `AppRuntime` that `run()` returns. */ +function fakeApp(): EventTarget { + return new EventTarget(); +} + +// There is no `ErrorEvent` in this environment. The SDK only reads `.error` off the event, which is +// where the runtime puts the thrown value. +function errorEvent(error: unknown): Event { + return Object.assign(new Event('error'), { error }); +} + +describe('captureRuntimeErrors', () => { + beforeAll(() => { + // Called twice because `init()` may run more than once. Two guards make that safe, the subscribe + // once flag and the `attached` WeakSet; this pins the outcome, not either one on its own. + instrumentClientRuntime(); + instrumentClientRuntime(); + }); + + it('reports one error per dispatch, however often instrumentation was set up', () => { + captureException.mockClear(); + const app = fakeApp(); + tracingChannel('orchestrion:@remix-run/ui:run').end.publish({ result: app }); + + const error = new Error('render failed'); + app.dispatchEvent(errorEvent(error)); + + expect(captureException).toHaveBeenCalledTimes(1); + expect(captureException).toHaveBeenCalledWith(error, { + mechanism: { handled: false, type: 'auto.ui.remix_v3' }, + }); + }); + + it('attaches only once to the same app', () => { + captureException.mockClear(); + const app = fakeApp(); + captureRuntimeErrors(app); + captureRuntimeErrors(app); + + app.dispatchEvent(errorEvent(new Error('render failed'))); + + expect(captureException).toHaveBeenCalledTimes(1); + }); + + it('falls back to the event when it carries no error', () => { + captureException.mockClear(); + const app = fakeApp(); + captureRuntimeErrors(app); + + const event = new Event('error'); + app.dispatchEvent(event); + + expect(captureException).toHaveBeenCalledWith(event, expect.anything()); + }); +}); diff --git a/packages/remix/test/v3/instrument.test.ts b/packages/remix/test/v3/instrument.test.ts index 7eddc88c84d3..1a564d1f1758 100644 --- a/packages/remix/test/v3/instrument.test.ts +++ b/packages/remix/test/v3/instrument.test.ts @@ -1,16 +1,32 @@ import { channel } from 'node:diagnostics_channel'; +import type * as SentryCore from '@sentry/core'; import { remixV3Channels } from '@sentry/server-utils/orchestrion/config'; -import { beforeAll, describe, expect, it } from 'vitest'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { instrumentRemixV3 } from '../../src/v3/server/instrument'; +const captureException = vi.fn(); + +// Only `captureException` is replaced; the middleware needs the rest for real. +vi.mock('@sentry/core', async importOriginal => ({ + ...(await importOriginal()), + captureException: (...args: unknown[]) => captureException(...args), + // Runs the callback as is, so the spy on `console.error` below sees the call. + consoleSandbox: (callback: () => unknown) => callback(), +})); + +const { instrumentRemixV3 } = await import('../../src/v3/server/instrument'); const startChannel = channel(`tracing:${remixV3Channels.REMIX_V3_CREATE_ROUTER}:start`); +const listenerStartChannel = channel(`tracing:${remixV3Channels.REMIX_V3_CREATE_REQUEST_LISTENER}:start`); /** What orchestrion's transform publishes: the call's arguments, collected into a real array. */ function publishCreateRouter(args: unknown[]): void { startChannel.publish({ arguments: args }); } +function publishCreateRequestListener(args: unknown[]): void { + listenerStartChannel.publish({ arguments: args }); +} + describe('instrumentRemixV3', () => { beforeAll(() => { // Called twice on purpose: the `--import` entry and `setupOnce()` both call it, and a second set @@ -68,3 +84,54 @@ describe('instrumentRemixV3', () => { expect(options.middleware).toEqual([]); }); }); + +describe('the createRequestListener error hook', () => { + beforeAll(() => { + instrumentRemixV3(); + }); + + beforeEach(() => { + captureException.mockClear(); + }); + + it("captures and then calls the app's own onError", async () => { + const appResponse = new Response('handled', { status: 500 }); + const appOnError = vi.fn(() => appResponse); + const options: Record = { onError: appOnError }; + + publishCreateRequestListener([() => new Response(), options]); + const error = new Error('boom'); + const returned = await (options.onError as (e: unknown) => Promise)(error); + + expect(captureException).toHaveBeenCalledWith(error, { + mechanism: { handled: false, type: 'auto.http.remix_v3.on_error' }, + }); + expect(appOnError).toHaveBeenCalledWith(error); + expect(returned).toBe(appResponse); + }); + + it('keeps the default console logging when the app passed no onError', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const args: unknown[] = [() => new Response()]; + + publishCreateRequestListener(args); + const error = new Error('boom'); + const returned = await (args[1] as { onError: (e: unknown) => unknown }).onError(error); + + expect(captureException).toHaveBeenCalledWith(error, { + mechanism: { handled: false, type: 'auto.http.remix_v3.on_error' }, + }); + // What the listener's own default handler does, which the hook replaced. + expect(consoleError).toHaveBeenCalledWith(error); + expect(returned).toBeUndefined(); + consoleError.mockRestore(); + }); + + it('installs a hook when the app passed no options at all', () => { + const args: unknown[] = [() => new Response()]; + + publishCreateRequestListener(args); + + expect(args[1]).toEqual(expect.objectContaining({ onError: expect.any(Function) })); + }); +}); diff --git a/packages/remix/test/v3/orchestrionLoader.test.ts b/packages/remix/test/v3/orchestrionLoader.test.ts new file mode 100644 index 000000000000..b076cad0a18b --- /dev/null +++ b/packages/remix/test/v3/orchestrionLoader.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { orchestrionLoader } from '../../src/v3/orchestrionLoader'; + +describe('orchestrionLoader', () => { + const loader = orchestrionLoader('/assets/npm/shim.js'); + const compiled = { format: 'module', source: 'export const a = 1;' }; + + it('serves a module it cannot locate on disk unchanged, rather than failing the compile', () => { + // Not a `file:` URL, so there is no path to read a package name from. + expect(loader('data:text/javascript,export%20const%20a=1', {}, () => compiled)).toBe(compiled); + }); + + it('serves a module outside node_modules unchanged', () => { + expect(loader('file:///app/entry.ts', {}, () => compiled)).toBe(compiled); + }); + + it('leaves non module results alone', () => { + const css = { format: 'css', source: 'a{}' }; + expect(loader('file:///app/a.css', {}, () => css)).toBe(css); + }); +}); diff --git a/packages/remix/test/v3/sdk.test.ts b/packages/remix/test/v3/sdk.test.ts new file mode 100644 index 000000000000..1436966ddb91 --- /dev/null +++ b/packages/remix/test/v3/sdk.test.ts @@ -0,0 +1,71 @@ +import type * as SentryBrowser from '@sentry/browser'; +import type { BrowserOptions } from '@sentry/browser'; +import { describe, expect, it, vi } from 'vitest'; + +const browserInit = vi.fn(); + +// Only `init` is replaced. Everything else stays real, so the integration assertion below is checked +// against the actual `@sentry/browser` defaults. +vi.mock('@sentry/browser', async importOriginal => ({ + ...(await importOriginal()), + init: (options: BrowserOptions) => browserInit(options), +})); + +const { getDefaultIntegrations, init } = await import('../../src/v3/client/sdk'); + +describe('getDefaultIntegrations', () => { + // `@sentry/browser`'s own defaults do not include browser tracing today, so this list adds it. If + // that ever changes upstream, a Remix 3 app would quietly end up with two browser tracing + // integrations, one of which reports no navigations at all. + it('adds exactly one browser tracing integration', () => { + const integrations = getDefaultIntegrations({}); + + expect(integrations.filter(integration => integration.name === 'BrowserTracing')).toHaveLength(1); + }); +}); + +describe('init', () => { + // The Remix 3 code ships inside `@sentry/remix`, so that is the package events have to name. Any + // other name is not installable from npm. + it('reports installable package names', () => { + init({}); + + expect(browserInit).toHaveBeenCalledWith( + expect.objectContaining({ + _metadata: { + sdk: { + name: 'sentry.javascript.remix', + version: expect.any(String), + packages: [ + { name: 'npm:@sentry/remix', version: expect.any(String) }, + { name: 'npm:@sentry/browser', version: expect.any(String) }, + ], + }, + }, + }), + ); + }); +}); + +describe('the client entry', () => { + // For the span start APIs the two packages export different implementations, and only + // `@sentry/browser`'s installs span streaming on first use. Taking one from `@sentry/core` is + // invisible until an app replaces the default integrations, because browser tracing installs span + // streaming anyway. + it('re-exports @sentry/browser, never a @sentry/core lookalike', async () => { + const [entry, browser] = await Promise.all([ + import('../../src/v3/index.client'), + vi.importActual('@sentry/browser'), + ]); + + // The three the Remix 3 SDK deliberately replaces. + const overridden = ['init', 'getDefaultIntegrations', 'browserTracingIntegration']; + const shared = Object.keys(entry).filter(name => name in browser && !overridden.includes(name)); + const wrongSource = shared.filter( + name => entry[name as keyof typeof entry] !== browser[name as keyof typeof browser], + ); + + expect(shared.length).toBeGreaterThan(20); + expect(wrongSource).toEqual([]); + }); +}); diff --git a/packages/server-runtime-injection/package.json b/packages/server-runtime-injection/package.json index 969f1ab79ccf..a60a35c2280b 100644 --- a/packages/server-runtime-injection/package.json +++ b/packages/server-runtime-injection/package.json @@ -52,7 +52,7 @@ "@apm-js-collab/code-transformer": "^0.18.1", "@apm-js-collab/tracing-hooks": "^0.13.3", "@types/node": "^18.19.1", - "@vercel/nft": "^1.3.0", + "@vercel/nft": "^1.11.0", "meriyah": "^6.1.4" }, "scripts": { diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index ca5a49171ea6..3f4d3bf245d4 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -51,6 +51,11 @@ "import": "./build/esm/orchestrion/bundler/webpack-loader.js", "require": "./build/cjs/orchestrion/bundler/webpack-loader.js" }, + "./orchestrion/load-hook": { + "types": "./build/types/orchestrion/bundler/load-hook.d.ts", + "import": "./build/esm/orchestrion/bundler/load-hook.js", + "require": "./build/cjs/orchestrion/bundler/load-hook.js" + }, "./orchestrion/esbuild": { "types": "./build/types/orchestrion/bundler/esbuild.d.ts", "import": "./build/esm/orchestrion/bundler/esbuild.js", @@ -81,6 +86,9 @@ ], "orchestrion/bun": [ "build/types/orchestrion/bundler/bun.d.ts" + ], + "orchestrion/load-hook": [ + "build/types/orchestrion/bundler/load-hook.d.ts" ] } }, @@ -89,7 +97,7 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0" }, "devDependencies": { diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index c313a9f1cc4c..20bfde7d0216 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -103,6 +103,7 @@ export default [ 'src/orchestrion/bundler/rollup.ts', 'src/orchestrion/bundler/webpack.ts', 'src/orchestrion/bundler/webpack-loader.ts', + 'src/orchestrion/bundler/load-hook.ts', 'src/orchestrion/bundler/esbuild.ts', 'src/orchestrion/bundler/bun.ts', ], diff --git a/packages/server-utils/src/ai/core/apiPromise.ts b/packages/server-utils/src/ai/core/apiPromise.ts index 245a8f4bd9ec..00e2769afea5 100644 --- a/packages/server-utils/src/ai/core/apiPromise.ts +++ b/packages/server-utils/src/ai/core/apiPromise.ts @@ -13,8 +13,9 @@ function isApiPromise(value: unknown): value is ApiPromise { // OpenAI and Anthropic return an APIPromise that extends the native promise objects and redefines .then() in a way that internally triggers body parsing. // This can lead to double parsing if our instrumentation triggers .then on this promise. // Instead, we need to avoid triggering .then on the APIPromise and instead observe the internal parsing process to get the response body. -// APIPromise implementation: https://github.com/openai/openai-node/blob/main/src/core/api-promise.ts -export function onApiPromiseResponse( +// OpenAI APIPromise: https://github.com/openai/openai-node/blob/71d24120c4cc4e5897a767f16d25f2804fa4ad7c/src/core/api-promise.ts +// Anthropic APIPromise: https://github.com/anthropics/anthropic-sdk-typescript/blob/d49bdab458000bcdffe77bd84b03293f31824fb3/src/core/api-promise.ts +export function wrapApiPromiseResponse( result: unknown, onResponse: (response: unknown) => void, onError: (error: unknown) => void, diff --git a/packages/server-utils/src/ai/typesafe/index.ts b/packages/server-utils/src/ai/typesafe/index.ts index b8fe48878024..328853cf5b02 100644 --- a/packages/server-utils/src/ai/typesafe/index.ts +++ b/packages/server-utils/src/ai/typesafe/index.ts @@ -49,17 +49,19 @@ function getRequestAttributes( [GEN_AI_OPERATION_NAME]: 'evaluate', [GEN_AI_PROVIDER_NAME]: TYPESAFE_PROVIDER_NAME, ...(model ? { [GEN_AI_REQUEST_MODEL]: model } : {}), - ...(recordInputs - ? { - [GEN_AI_INPUT_MESSAGES]: stringify([ - { type: 'evaluation', state: request.state, questions: request.questions }, - ]), - } - : {}), + ...(recordInputs ? { [GEN_AI_INPUT_MESSAGES]: getEvaluationInputMessages(request) } : {}), }; } -/** Add the response model, token usage and (optionally) the answers of a `systemOne` result. */ +/** Serialize the `state` and `questions` of an evaluation request. Also used for TypeSafe models on Workers AI. */ +export function getEvaluationInputMessages(request: Record): string | undefined { + return stringify([{ type: 'evaluation', state: request.state, questions: request.questions }]); +} + +/** + * Add the response model, token usage and (optionally) the answers of a `systemOne` result. + * Also used for TypeSafe models on Workers AI. + */ export function addResponseAttributes(span: Span, result: unknown, recordOutputs: boolean): void { if (!isObjectLike(result)) { return; diff --git a/packages/server-utils/src/ai/workers-ai/index.ts b/packages/server-utils/src/ai/workers-ai/index.ts index c02222cc1877..eb3a7c5407cb 100644 --- a/packages/server-utils/src/ai/workers-ai/index.ts +++ b/packages/server-utils/src/ai/workers-ai/index.ts @@ -89,7 +89,7 @@ function instrumentRun( } // The model did not actually return a stream — finalize the span eagerly. - addResponseAttributes(span, result, options.recordOutputs); + addResponseAttributes(span, result, options.recordOutputs, operationName); span.end(); return result; }, handleError); @@ -105,7 +105,7 @@ function instrumentRun( return originalResult.then(result => { if (!returnsRawResponse) { - addResponseAttributes(span, result, options.recordOutputs); + addResponseAttributes(span, result, options.recordOutputs, operationName); } return result; }); diff --git a/packages/server-utils/src/ai/workers-ai/utils.ts b/packages/server-utils/src/ai/workers-ai/utils.ts index 33908290be72..67645a456988 100644 --- a/packages/server-utils/src/ai/workers-ai/utils.ts +++ b/packages/server-utils/src/ai/workers-ai/utils.ts @@ -16,10 +16,16 @@ import { GEN_AI_SYSTEM_INSTRUCTIONS, } from '@sentry/conventions/attributes'; import { GEN_AI_CHAT, GEN_AI_EMBEDDINGS } from '@sentry/conventions/op'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, stringify } from '@sentry/core'; +import { isObjectLike, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, stringify } from '@sentry/core'; import type { Span, SpanAttributeValue } from '@sentry/core'; import { GEN_AI_REQUEST_STREAM_ATTRIBUTE } from '../core/gen-ai-attributes'; -import { extractSystemInstructions, setOutputMessagesAttribute, setTokenUsageAttributes } from '../core/utils'; +import { + extractSystemInstructions, + getGenAiSpanOp, + setOutputMessagesAttribute, + setTokenUsageAttributes, +} from '../core/utils'; +import { addResponseAttributes as addEvaluateResponseAttributes, getEvaluationInputMessages } from '../typesafe'; // Re-exported so `workers-ai/streaming.ts` keeps importing it from this module. export { setOutputMessagesAttribute }; import { WORKERS_AI_ORIGIN, WORKERS_AI_PROVIDER_NAME } from './constants'; @@ -29,15 +35,20 @@ import type { WorkersAiInput, WorkersAiOutput } from './types'; * Determine the gen_ai operation name from the inputs passed to `AI.run`. * Workers AI exposes a single `run` method, so we infer the operation from the input shape. */ -export type WorkersAiOperationName = 'chat' | 'embeddings'; +export type WorkersAiOperationName = 'chat' | 'embeddings' | 'evaluate'; export const WORKERS_AI_OPERATION_SPAN_OPS: Record = { chat: GEN_AI_CHAT, embeddings: GEN_AI_EMBEDDINGS, + evaluate: getGenAiSpanOp('evaluate'), }; export function getOperationName(inputs: unknown): WorkersAiOperationName { if (inputs && typeof inputs === 'object') { + // TypeSafe evaluation models (e.g. `typesafe/jev`) + if ('state' in inputs && 'questions' in inputs) { + return 'evaluate'; + } if ('messages' in inputs || 'prompt' in inputs) { return 'chat'; } @@ -102,6 +113,11 @@ export function addRequestAttributes(span: Span, inputs: unknown, operationName: } const params = inputs as WorkersAiInput; + if (operationName === 'evaluate') { + span.setAttribute(GEN_AI_INPUT_MESSAGES, getEvaluationInputMessages(inputs as Record)); + return; + } + // Store embeddings input on a separate attribute if (operationName === 'embeddings') { const text = params.text; @@ -132,7 +148,19 @@ export function addRequestAttributes(span: Span, inputs: unknown, operationName: /** * Record the response attributes (token usage, response text, tool calls) on the span. */ -export function addResponseAttributes(span: Span, result: unknown, recordOutputs: boolean): void { +export function addResponseAttributes( + span: Span, + result: unknown, + recordOutputs: boolean, + operationName?: WorkersAiOperationName, +): void { + if (operationName === 'evaluate') { + // The binding wraps the TypeSafe response as `{ state, result }`, but the docs show it unwrapped. + const body = isObjectLike(result) && isObjectLike(result.result) ? result.result : result; + addEvaluateResponseAttributes(span, body, recordOutputs); + return; + } + if ( !result || typeof result !== 'object' || diff --git a/packages/server-utils/src/integrations/anthropic.ts b/packages/server-utils/src/integrations/anthropic.ts index be7421fb3bcd..33517d4730c2 100644 --- a/packages/server-utils/src/integrations/anthropic.ts +++ b/packages/server-utils/src/integrations/anthropic.ts @@ -4,13 +4,14 @@ import type { IntegrationFn, Span, SpanAttributeValue } from '@sentry/core'; import { _INTERNAL_shouldSkipAiProviderWrapping, defineIntegration, + getActiveSpan, getClient, hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, } from '@sentry/core'; import { getGenAiSpanOp, resolveAIRecordingOptions } from '../ai/core/utils'; -import { onApiPromiseResponse } from '../ai/core/apiPromise'; +import { wrapApiPromiseResponse } from '../ai/core/apiPromise'; import { addPrivateRequestAttributes, addResponseAttributes, extractRequestAttributes } from '../ai/anthropic-ai'; import { instrumentAsyncIterableStream, instrumentMessageStream } from '../ai/anthropic-ai/streaming'; import type { AnthropicAiOptions, AnthropicAiResponse } from '../ai/anthropic-ai/types'; @@ -55,7 +56,7 @@ function instrumentAnthropic(options: AnthropicAiOptions): void { for (const { channel, operation, stream } of INSTRUMENTED_CHANNELS) { bindTracingChannelToSpan( diagnosticsChannel.tracingChannel(channel), - data => createGenAiSpan(data, operation, options), + data => createGenAiSpan(data, operation, options, stream), { beforeSpanEnd: (span, data) => { addResponseAttributes( @@ -65,7 +66,7 @@ function instrumentAnthropic(options: AnthropicAiOptions): void { ); }, deferSpanEnd: ({ span, data, end }) => - onApiPromiseResponse( + wrapApiPromiseResponse( data.result, response => { data.result = response; @@ -88,6 +89,7 @@ function createGenAiSpan( data: AnthropicChannelContext, operation: string, options: AnthropicAiOptions, + stream: StreamMode, ): Span | undefined { const args = data.arguments ?? []; @@ -98,10 +100,12 @@ function createGenAiSpan( } // `messages.stream()` internally calls the instrumented `messages.create({ stream: true })` tagged with - // an `X-Stainless-Helper-Method: 'stream'` header. The messages-stream channel already covers it, so skip - // the nested create to avoid a duplicate span. - const requestOptions = args[1] as { headers?: Record } | undefined; - if (requestOptions?.headers?.['X-Stainless-Helper-Method'] === 'stream') { + // a `stream` helper-method header. The messages-stream channel already covers it, so skip the nested + // create to avoid a duplicate span. Only the non-beta helper is on that channel, though: + // `beta.messages.stream()` and the streaming tool runner send the same header with no span covering + // them, so the header alone is not enough. Skip only while a stream-helper span of ours is active. + const requestOptions = args[1] as { headers?: unknown } | undefined; + if (isStreamHelperRequest(requestOptions?.headers) && isInsideStreamHelperSpan()) { return undefined; } @@ -125,9 +129,48 @@ function createGenAiSpan( addPrivateRequestAttributes(span, params); } + if (stream === 'message-stream') { + streamHelperSpans.add(span); + } + return span; } +const STREAM_HELPER_METHOD_HEADER = 'x-stainless-helper-method'; + +/** The spans opened for the messages-stream channel, i.e. for `messages.stream()` calls. */ +const streamHelperSpans = new WeakSet(); + +/** + * Whether the active span is one this integration opened for `messages.stream()`. The helper's + * internal `create` runs inside that span, so this is what tells it apart from the beta helper and + * the tool runner, which send the same header but are not on the messages-stream channel. + */ +function isInsideStreamHelperSpan(): boolean { + const activeSpan = getActiveSpan(); + return !!activeSpan && streamHelperSpans.has(activeSpan); +} + +/** + * Whether request options carry the header the SDK's `messages.stream()` helper puts on its internal + * `create` call. The SDK sent it as `X-Stainless-Helper-Method` up to 0.105 and lowercase since 0.106, and + * HTTP header names are case-insensitive either way, so match without regard to case. The headers + * arrive as a plain object; a `Headers` instance is handled for completeness. + */ +function isStreamHelperRequest(headers: unknown): boolean { + if (!headers || typeof headers !== 'object') { + return false; + } + + if (typeof (headers as Headers).get === 'function') { + return (headers as Headers).get(STREAM_HELPER_METHOD_HEADER) === 'stream'; + } + + return Object.entries(headers as Record).some( + ([name, value]) => name.toLowerCase() === STREAM_HELPER_METHOD_HEADER && value === 'stream', + ); +} + type AsyncIterableStream = { [Symbol.asyncIterator]: () => AsyncIterator }; type MessageStreamEmitter = { on: (...args: unknown[]) => void }; diff --git a/packages/server-utils/src/integrations/fastify/utils.ts b/packages/server-utils/src/integrations/fastify/utils.ts index e560590d9234..fe6f6a9235ec 100644 --- a/packages/server-utils/src/integrations/fastify/utils.ts +++ b/packages/server-utils/src/integrations/fastify/utils.ts @@ -1,3 +1,4 @@ +import { isObjectLike } from '@sentry/core'; import type { FastifyReply, FastifyRequest } from './types'; export const INTEGRATION_NAME = 'Fastify' as const; @@ -7,8 +8,30 @@ export const INTEGRATION_NAME = 'Fastify' as const; * * 3xx and 4xx errors are not sent by default. */ -export function defaultShouldHandleError(_error: Error, _request: FastifyRequest, reply: FastifyReply): boolean { - const statusCode = reply.statusCode; +export function defaultShouldHandleError(error: Error, _request: FastifyRequest, reply: FastifyReply): boolean { + const statusCode = getReplyStatusCode(error, reply); // 3xx and 4xx errors are not sent by default. return statusCode >= 500 || statusCode <= 299; } + +/** + * Fastify runs `onError` hooks before it applies the error's status to the reply + * (e.g. for a request body that fails to parse), so `reply.statusCode` can still be + * the default 200. In that case, resolve the status the same way Fastify will. + */ +function getReplyStatusCode(error: Error, reply: FastifyReply): number { + if (reply.statusCode !== 200) { + return reply.statusCode; + } + + const errorStatusCode = Number(getErrorStatusCode(error)); + return errorStatusCode >= 400 ? errorStatusCode : 500; +} + +function getErrorStatusCode(error: unknown): unknown { + if (!isObjectLike(error)) { + return undefined; + } + + return ('statusCode' in error && error.statusCode) || ('status' in error && error.status); +} diff --git a/packages/server-utils/src/integrations/openai-compatible.ts b/packages/server-utils/src/integrations/openai-compatible.ts index fd0b0ccbc918..be98c3de7a9e 100644 --- a/packages/server-utils/src/integrations/openai-compatible.ts +++ b/packages/server-utils/src/integrations/openai-compatible.ts @@ -9,7 +9,7 @@ import { } from '@sentry/core'; import { GEN_AI_PROVIDER_NAME } from '@sentry/conventions/attributes'; import { getGenAiSpanOp, resolveAIRecordingOptions } from '../ai/core/utils'; -import { onApiPromiseResponse } from '../ai/core/apiPromise'; +import { wrapApiPromiseResponse } from '../ai/core/apiPromise'; import { addRequestAttributes, extractRequestAttributes } from '../ai/openai'; import { instrumentStream } from '../ai/openai/streaming'; import type { OpenAiOptions } from '../ai/openai/types'; @@ -68,7 +68,7 @@ export function createOpenAiCompatibleIntegration - onApiPromiseResponse( + wrapApiPromiseResponse( data.result, response => { data.result = response; diff --git a/packages/server-utils/src/integrations/openai.ts b/packages/server-utils/src/integrations/openai.ts index cf16af786953..ada6e60411a2 100644 --- a/packages/server-utils/src/integrations/openai.ts +++ b/packages/server-utils/src/integrations/openai.ts @@ -10,7 +10,7 @@ import { } from '@sentry/core'; import { getGenAiSpanOp, resolveAIRecordingOptions } from '../ai/core/utils'; import { addRequestAttributes, extractRequestAttributes } from '../ai/openai'; -import { onApiPromiseResponse } from '../ai/core/apiPromise'; +import { wrapApiPromiseResponse } from '../ai/core/apiPromise'; import { instrumentStream } from '../ai/openai/streaming'; import type { OpenAiOptions } from '../ai/openai/types'; import { addResponseAttributes } from '../ai/openai/utils'; @@ -60,7 +60,7 @@ function instrumentOpenai(options: OpenAiOptions): void { addResponseAttributes(span, data.result, resolveAIRecordingOptions(options).recordOutputs); }, deferSpanEnd: ({ span, data, end }) => - onApiPromiseResponse( + wrapApiPromiseResponse( data.result, response => { data.result = response; diff --git a/packages/server-utils/src/integrations/redis/redis-cache.ts b/packages/server-utils/src/integrations/redis/redis-cache.ts index 428facdb01c6..ac74bee358f4 100644 --- a/packages/server-utils/src/integrations/redis/redis-cache.ts +++ b/packages/server-utils/src/integrations/redis/redis-cache.ts @@ -1,4 +1,7 @@ import { + CACHE_HIT, + CACHE_ITEM_SIZE, + CACHE_KEY, CACHE_OPERATION, NETWORK_PEER_ADDRESS, NETWORK_PEER_PORT, @@ -11,9 +14,6 @@ import { CACHE_OPERATION_NAMES, getClient, hasSpanStreamingEnabled, - SEMANTIC_ATTRIBUTE_CACHE_HIT, - SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE, - SEMANTIC_ATTRIBUTE_CACHE_KEY, SEMANTIC_ATTRIBUTE_SENTRY_OP, spanToJSON, truncate, @@ -175,7 +175,7 @@ export function getRedisCacheAttributes( const attributes: SpanAttributes = { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: cacheOperation, - [SEMANTIC_ATTRIBUTE_CACHE_KEY]: safeKey, + [CACHE_KEY]: safeKey, [CACHE_OPERATION]: CACHE_OPERATION_NAMES[cacheOperation], }; @@ -216,11 +216,11 @@ export function applyCacheResponseAttributes(span: Span, response: unknown): voi const cacheItemSize = calculateCacheItemSize(response); if (cacheItemSize) { - span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE, cacheItemSize); + span.setAttribute(CACHE_ITEM_SIZE, cacheItemSize); } if (op === CACHE_GET && cacheItemSize !== undefined) { - span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, cacheItemSize > 0); + span.setAttribute(CACHE_HIT, cacheItemSize > 0); } } diff --git a/packages/server-utils/src/orchestrion/bundler/load-hook.ts b/packages/server-utils/src/orchestrion/bundler/load-hook.ts new file mode 100644 index 000000000000..7473254eb9d3 --- /dev/null +++ b/packages/server-utils/src/orchestrion/bundler/load-hook.ts @@ -0,0 +1,41 @@ +// The transform for a caller with a file path and its source and no bundler in between. Bundled like +// the webpack loader: the `@apm-js-collab` packages are devDependencies, not resolvable on user installs. +import { createCodeTransformer } from '@apm-js-collab/code-transformer-bundler-plugins/core'; +import { SENTRY_INSTRUMENTATIONS } from '../config'; +import type { InstrumentationConfig } from '../apmTypes'; + +export interface LoadHookTransformOptions { + /** + * Module specifier the transformed code imports its `tracingChannel` from, in place of + * `node:diagnostics_channel`. In a browser this is a URL the module loader can fetch. + */ + dcModule: string; + /** Defaults to every built in instrumentation. */ + instrumentations?: InstrumentationConfig[]; +} + +/** + * Instruments `code` when `filePath` is in an instrumented package, read from the nearest + * `package.json`. Returns `undefined` when it is not, or the transform fails, so the caller serves the + * module as is. + */ +export function createLoadHookTransform( + options: LoadHookTransformOptions, +): (filePath: string, code: string) => string | undefined { + const { transform } = createCodeTransformer({ + instrumentations: options.instrumentations ?? SENTRY_INSTRUMENTATIONS, + dcModule: options.dcModule, + }); + + return (filePath, code) => { + // Only third party modules carry instrumentation configs. + if (!/[\\/]node_modules[\\/]/.test(filePath)) { + return undefined; + } + try { + return transform(code, filePath)?.code; + } catch { + return undefined; + } + }; +} diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index 9ed93c98c0bf..f8dded8a6a6d 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -195,5 +195,6 @@ export function withoutInstrumentedExternals( export { nestjsChannels } from './nestjs'; // This is exported so that the remix package can use it to subscribe to the channels. export { remixChannels } from './remix'; -// This is exported so the remix package can subscribe to the Remix 3 channels. -export { remixV3Channels } from './remix-v3'; +// Exported so the remix package can subscribe to the Remix 3 channels, and hand only these configs to +// the browser transform. +export { remixV3Channels, remixV3Config } from './remix-v3'; diff --git a/packages/server-utils/src/orchestrion/config/remix-v3.ts b/packages/server-utils/src/orchestrion/config/remix-v3.ts index ee30e3ffdcf7..8dfc20daf5b5 100644 --- a/packages/server-utils/src/orchestrion/config/remix-v3.ts +++ b/packages/server-utils/src/orchestrion/config/remix-v3.ts @@ -19,8 +19,35 @@ export const remixV3Config: InstrumentationConfig[] = [ }, functionQuery: { functionName: 'createRouter', kind: 'Sync' }, }, + // The subscriber rewrites the options before the asset server reads them and wraps the server it + // returns, so browser modules carry debug IDs without any config from the app. + { + channelName: 'createAssetServer', + module: { name: '@remix-run/assets', versionRange: '>=0.6.0 <1', filePath: 'dist/lib/asset-server.js' }, + functionQuery: { functionName: 'createAssetServer', kind: 'Sync' }, + }, + // The only error hook that covers an app whose fetch handler is not a router. + { + channelName: 'createRequestListener', + module: { + name: '@remix-run/node-fetch-server', + versionRange: '>=0.14.0 <1', + filePath: 'dist/lib/request-listener.js', + }, + functionQuery: { functionName: 'createRequestListener', kind: 'Sync' }, + }, + // Browser side, applied by the asset server's loader chain. The only way to see component render + // errors without application code. + { + channelName: 'run', + module: { name: '@remix-run/ui', versionRange: '>=0.8.0 <1', filePath: 'dist/runtime/run.js' }, + functionQuery: { functionName: 'run', kind: 'Sync' }, + }, ]; export const remixV3Channels = { REMIX_V3_CREATE_ROUTER: 'orchestrion:@remix-run/fetch-router:createRouter', + REMIX_V3_CREATE_ASSET_SERVER: 'orchestrion:@remix-run/assets:createAssetServer', + REMIX_V3_CREATE_REQUEST_LISTENER: 'orchestrion:@remix-run/node-fetch-server:createRequestListener', + REMIX_V3_UI_RUN: 'orchestrion:@remix-run/ui:run', } as const; diff --git a/packages/server-utils/test/ai/lib/tracing/workers-ai.test.ts b/packages/server-utils/test/ai/lib/tracing/workers-ai.test.ts index 4baced963ec2..bc282e0348bc 100644 --- a/packages/server-utils/test/ai/lib/tracing/workers-ai.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/workers-ai.test.ts @@ -5,8 +5,12 @@ import { GEN_AI_OUTPUT_MESSAGES, GEN_AI_PROVIDER_NAME, GEN_AI_REQUEST_MODEL, + GEN_AI_RESPONSE_MODEL, GEN_AI_RESPONSE_TEXT, GEN_AI_SYSTEM_INSTRUCTIONS, + GEN_AI_USAGE_INPUT_TOKENS, + GEN_AI_USAGE_OUTPUT_TOKENS, + GEN_AI_USAGE_TOTAL_TOKENS, } from '@sentry/conventions/attributes'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -160,6 +164,45 @@ describe('instrumentWorkersAiClient', () => { }); }); + it('records TypeSafe models as evaluate spans, like the TypeSafe integration', async () => { + const client = new TestClient( + getDefaultTestClientOptions({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1 }), + ); + setCurrentClient(client); + client.init(); + const endedSpans: Span[] = []; + client.on('spanEnd', span => endedSpans.push(span)); + + const questions = { is_urgent: { type: 'noul', instructions: 'Does this convey urgency?' } }; + const answers = { is_urgent: { type: 'noul', noul: 0.97 } }; + const ai = { + run: vi.fn().mockResolvedValue({ + state: 'Completed', + result: { model: 'jev-1.13.0', answers, usage: { input_tokens: 426, output_tokens: 73 } }, + }), + }; + + await instrumentWorkersAiClient(ai).run('typesafe/jev', { state: 'Help!', questions }); + + const span = spanToStaticSpanJSON(endedSpans[0]!); + expect(span.description).toBe('evaluate typesafe/jev'); + expect(span.data).toEqual({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.cloudflare.workers_ai', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.evaluate', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + [SENTRY_SEGMENT_NAME_SOURCE]: 'custom', + [GEN_AI_PROVIDER_NAME]: 'cloudflare.workers_ai', + [GEN_AI_OPERATION_NAME]: 'evaluate', + [GEN_AI_REQUEST_MODEL]: 'typesafe/jev', + [GEN_AI_RESPONSE_MODEL]: 'jev-1.13.0', + [GEN_AI_USAGE_INPUT_TOKENS]: 426, + [GEN_AI_USAGE_OUTPUT_TOKENS]: 73, + [GEN_AI_USAGE_TOTAL_TOKENS]: 499, + [GEN_AI_INPUT_MESSAGES]: JSON.stringify([{ type: 'evaluation', state: 'Help!', questions }]), + [GEN_AI_OUTPUT_MESSAGES]: JSON.stringify([{ type: 'evaluation', answers }]), + }); + }); + describe('span names', () => { function setupClient(traceLifecycle: 'static' | 'stream'): Span[] { const client = new TestClient( diff --git a/packages/server-utils/test/ai/lib/utils/workers-ai-utils.test.ts b/packages/server-utils/test/ai/lib/utils/workers-ai-utils.test.ts index 70878dc1a96e..8d9f9d15b257 100644 --- a/packages/server-utils/test/ai/lib/utils/workers-ai-utils.test.ts +++ b/packages/server-utils/test/ai/lib/utils/workers-ai-utils.test.ts @@ -14,6 +14,7 @@ import { GEN_AI_REQUEST_TOP_K, GEN_AI_REQUEST_TOP_P, GEN_AI_OUTPUT_MESSAGES, + GEN_AI_RESPONSE_MODEL, GEN_AI_RESPONSE_TEXT, GEN_AI_RESPONSE_TOOL_CALLS, GEN_AI_SYSTEM_INSTRUCTIONS, @@ -59,6 +60,10 @@ describe('workers-ai utils', () => { expect(getOperationName({ text: 'embed me' })).toBe('embeddings'); }); + it('returns "evaluate" for TypeSafe state and questions inputs', () => { + expect(getOperationName({ state: 'Help!', questions: {} })).toBe('evaluate'); + }); + it('prefers "chat" when both messages and text are present', () => { expect(getOperationName({ messages: [{ role: 'user', content: 'Hi' }], text: 'embed me' })).toBe('chat'); }); @@ -185,6 +190,29 @@ describe('workers-ai utils', () => { }); describe('addResponseAttributes', () => { + it.each([ + { name: 'wrapped in { state, result }', wrap: (body: object) => ({ state: 'Completed', result: body }) }, + { name: 'unwrapped', wrap: (body: object) => body }, + ])('records evaluate responses $name', ({ wrap }) => { + const { span, attributes } = createMockSpan(); + const answers = { is_urgent: { type: 'noul', noul: 0.95 } }; + + addResponseAttributes( + span, + wrap({ model: 'jev-1.13.0', answers, usage: { input_tokens: 366, output_tokens: 50 } }), + true, + 'evaluate', + ); + + expect(attributes).toEqual({ + [GEN_AI_RESPONSE_MODEL]: 'jev-1.13.0', + [GEN_AI_USAGE_INPUT_TOKENS]: 366, + [GEN_AI_USAGE_OUTPUT_TOKENS]: 50, + [GEN_AI_USAGE_TOTAL_TOKENS]: 416, + [GEN_AI_OUTPUT_MESSAGES]: JSON.stringify([{ type: 'evaluation', answers }]), + }); + }); + it('sets token usage and computes the total from input and output tokens', () => { const { span, attributes } = createMockSpan(); diff --git a/packages/server-utils/test/integrations/fastify/utils.test.ts b/packages/server-utils/test/integrations/fastify/utils.test.ts new file mode 100644 index 000000000000..24719807c62d --- /dev/null +++ b/packages/server-utils/test/integrations/fastify/utils.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import type { FastifyReply, FastifyRequest } from '../../../src/integrations/fastify/types'; +import { defaultShouldHandleError } from '../../../src/integrations/fastify/utils'; + +const request: FastifyRequest = { method: 'POST', routeOptions: { url: '/' } }; + +function reply(statusCode: number): FastifyReply { + return { statusCode } as FastifyReply; +} + +function errorWith(props: { statusCode?: number; status?: number }): Error { + return Object.assign(new Error('test error'), props); +} + +describe('defaultShouldHandleError', () => { + // Fastify runs `onError` hooks before it applies the error's status to the reply, + // e.g. for a body that fails to parse, so `reply.statusCode` is still the default 200 there. + describe('when the reply status has not been set yet', () => { + it('skips errors carrying a 4xx `statusCode`', () => { + expect(defaultShouldHandleError(errorWith({ statusCode: 400 }), request, reply(200))).toBe(false); + }); + + it('skips errors carrying a 4xx `status`', () => { + expect(defaultShouldHandleError(errorWith({ status: 404 }), request, reply(200))).toBe(false); + }); + + it('skips errors carrying a 4xx status as a string', () => { + expect( + defaultShouldHandleError(Object.assign(new Error('test error'), { statusCode: '404' }), request, reply(200)), + ).toBe(false); + }); + + it('captures errors carrying a 5xx status', () => { + expect(defaultShouldHandleError(errorWith({ statusCode: 503 }), request, reply(200))).toBe(true); + }); + + it('captures errors without a status', () => { + expect(defaultShouldHandleError(new Error('test error'), request, reply(200))).toBe(true); + }); + + it.each([null, undefined, 'a string', 404])( + 'captures a thrown non-object (%s), which Fastify sends as a 500', + error => { + expect(defaultShouldHandleError(error as unknown as Error, request, reply(200))).toBe(true); + }, + ); + + it('captures errors carrying a status below 400, which Fastify sends as a 500', () => { + expect(defaultShouldHandleError(errorWith({ statusCode: 302 }), request, reply(200))).toBe(true); + }); + }); + + describe('when the reply status has been set', () => { + it('skips errors on a 4xx reply', () => { + expect(defaultShouldHandleError(new Error('test error'), request, reply(404))).toBe(false); + }); + + it('skips errors on a 3xx reply', () => { + expect(defaultShouldHandleError(new Error('test error'), request, reply(302))).toBe(false); + }); + + it('captures errors on a 5xx reply', () => { + expect(defaultShouldHandleError(errorWith({ statusCode: 400 }), request, reply(500))).toBe(true); + }); + }); +}); diff --git a/packages/server-utils/test/orchestrion/load-hook.test.ts b/packages/server-utils/test/orchestrion/load-hook.test.ts new file mode 100644 index 000000000000..6a4d9af73ba6 --- /dev/null +++ b/packages/server-utils/test/orchestrion/load-hook.test.ts @@ -0,0 +1,46 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { remixV3Config } from '../../src/orchestrion/config'; +import { createLoadHookTransform } from '../../src/orchestrion/bundler/load-hook'; + +// The transform reads the package name and version from disk, so the fixture is a real, minimal +// install layout in a temp directory. +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'load-hook-')); +const pkgDir = path.join(root, 'node_modules', '@remix-run', 'ui'); +fs.mkdirSync(path.join(pkgDir, 'dist', 'runtime'), { recursive: true }); +fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({ name: '@remix-run/ui', version: '0.11.0' })); +const runFile = path.join(pkgDir, 'dist', 'runtime', 'run.js'); +const source = 'export function run(init) {\n return init;\n}\n'; +fs.writeFileSync(runFile, source); + +afterAll(() => fs.rmSync(root, { recursive: true, force: true })); + +describe('createLoadHookTransform', () => { + const transform = createLoadHookTransform({ dcModule: '/assets/npm/shim.js', instrumentations: remixV3Config }); + + it('instruments a matching module and imports the channel from dcModule', () => { + const code = transform(runFile, source); + + expect(code).toMatch(/from ["']\/assets\/npm\/shim\.js["']/); + expect(code).toContain('orchestrion:@remix-run/ui:run'); + // Emitted as an ES module, which is the only thing the asset server can serve. + expect(code).not.toMatch(/\brequire\(/); + }); + + it('leaves a file that is not in an instrumented package alone', () => { + const other = path.join(root, 'node_modules', 'left-pad', 'index.js'); + fs.mkdirSync(path.dirname(other), { recursive: true }); + fs.writeFileSync( + path.join(path.dirname(other), 'package.json'), + JSON.stringify({ name: 'left-pad', version: '1.0.0' }), + ); + + expect(transform(other, 'export const x = 1;')).toBeUndefined(); + }); + + it('leaves application code alone', () => { + expect(transform(path.join(root, 'app', 'entry.js'), source)).toBeUndefined(); + }); +}); diff --git a/packages/solid/package.json b/packages/solid/package.json index 2f351e908b40..9446d62923f9 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -56,7 +56,7 @@ "dependencies": { "@sentry/browser": "11.2.0", "@sentry/core": "11.2.0", - "@sentry/conventions": "^0.24.0" + "@sentry/conventions": "^0.25.0" }, "peerDependencies": { "@solidjs/router": ">=0.13.4 <2.0.0-0", diff --git a/packages/solidstart/package.json b/packages/solidstart/package.json index 02dd5a5a67c5..4797918df22d 100644 --- a/packages/solidstart/package.json +++ b/packages/solidstart/package.json @@ -87,7 +87,7 @@ "@sentry/server-utils": "11.2.0", "@sentry/solid": "11.2.0", "@sentry/bundler-plugins": "11.2.0", - "@sentry/conventions": "0.24.0" + "@sentry/conventions": "0.25.0" }, "devDependencies": { "@solidjs/router": "^1.0.0", diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 366f38fb4cd1..e9e17c934a0b 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -33,7 +33,7 @@ }, "dependencies": { "@sentry/browser": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "magic-string": "~0.30.0" }, diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index d1d290d618f6..9b7fb5b56bab 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -73,7 +73,7 @@ "@opentelemetry/api": "^1.9.1", "@sentry/cloudflare": "11.2.0", "@sentry/core": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/node": "11.2.0", "@sentry/server-runtime-injection": "11.2.0", "@sentry/server-utils": "11.2.0", diff --git a/packages/tanstackstart-react/package.json b/packages/tanstackstart-react/package.json index 9b583fab5387..589380f2cb94 100644 --- a/packages/tanstackstart-react/package.json +++ b/packages/tanstackstart-react/package.json @@ -53,7 +53,7 @@ "dependencies": { "@opentelemetry/api": "^1.9.1", "@sentry/browser-utils": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "@sentry/node": "11.2.0", "@sentry/react": "11.2.0", diff --git a/packages/tanstackstart-react/src/vite/sourceMaps.ts b/packages/tanstackstart-react/src/vite/sourceMaps.ts index 742f4227758b..817cad432a22 100644 --- a/packages/tanstackstart-react/src/vite/sourceMaps.ts +++ b/packages/tanstackstart-react/src/vite/sourceMaps.ts @@ -101,9 +101,7 @@ export function makeEnableSourceMapsVitePlugin(options: SentryTanstackStartOptio enforce: 'post', config(viteConfig) { return { - ...viteConfig, build: { - ...viteConfig.build, sourcemap: getUpdatedSourceMapSettings(viteConfig, options), }, }; diff --git a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts index abf8f24301da..e5ea14c95b3e 100644 --- a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts +++ b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts @@ -1,5 +1,7 @@ +// @vitest-environment node import type { SentryVitePluginOptions } from '@sentry/bundler-plugins/vite'; import type { UserConfig } from 'vite'; +import { mergeConfig } from 'vite'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getUpdatedSourceMapSettings, @@ -34,6 +36,34 @@ describe('makeEnableSourceMapsVitePlugin()', () => { expect(sourceMapsConfigPlugins).toHaveLength(1); }); + + it.each([undefined, false, true, 'hidden', 'inline'] as const)( + 'preserves user config without duplicating arrays when sourcemap is %s', + sourcemap => { + const plugin = makeEnableSourceMapsVitePlugin({})[0]!; + const configHook = plugin.config as (config: UserConfig) => UserConfig; + const userConfig: UserConfig = { + optimizeDeps: { include: ['react'] }, + build: { + sourcemap, + outDir: 'custom-dist', + rollupOptions: { plugins: [{ name: 'asset-transform' }] }, + }, + }; + + const merged = mergeConfig(userConfig, configHook(userConfig)); + + expect(merged).toEqual({ + optimizeDeps: { include: ['react'] }, + build: { + sourcemap: sourcemap ?? 'hidden', + outDir: 'custom-dist', + rollupOptions: { plugins: [{ name: 'asset-transform' }] }, + rolldownOptions: { plugins: [{ name: 'asset-transform' }] }, + }, + }); + }, + ); }); describe('makeAddSentryVitePlugin()', () => { diff --git a/packages/vue/package.json b/packages/vue/package.json index 5c370f0d9ef9..aa524f3cd0fd 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -46,7 +46,7 @@ "dependencies": { "@sentry/browser": "11.2.0", "@sentry/core": "11.2.0", - "@sentry/conventions": "^0.24.0" + "@sentry/conventions": "^0.25.0" }, "peerDependencies": { "@tanstack/vue-router": "^1.64.0", diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 3e870ff1062b..00d44693e193 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -8,5 +8,6 @@ export { browserTracingIntegration } from './browserTracingIntegration'; export { attachErrorHandler } from './errorhandler'; export { createTracingMixins } from './tracing'; export { vueIntegration } from './integration'; +export { createVueRouteProvider as _INTERNAL_createVueRouteProvider } from './routeProvider'; export type { VueIntegrationOptions } from './integration'; export { createSentryPiniaPlugin } from './pinia'; diff --git a/packages/vue/src/integration.ts b/packages/vue/src/integration.ts index 8bdac3d5db44..21835f8234dc 100644 --- a/packages/vue/src/integration.ts +++ b/packages/vue/src/integration.ts @@ -1,8 +1,10 @@ +import { getRouteProvider, setRouteProvider } from '@sentry/browser'; import { consoleSandbox, defineIntegration, GLOBAL_OBJ, hasSpansEnabled } from '@sentry/core'; import { DEFAULT_HOOKS, DEFAULT_ROOT_SPAN_TIMEOUT } from './constants'; import { DEBUG_BUILD } from './debug-build'; import { attachErrorHandler } from './errorhandler'; import { instrumentAppMountWithoutMixin } from './rootInstrumentation'; +import { captureRouterFromVue, createVueRouteProvider, getRouterFromApp } from './routeProvider'; import { createTracingMixins } from './tracing'; import type { Options, TracingOptions, Vue, VueOptions } from './types'; @@ -38,6 +40,13 @@ export const vueIntegration = defineIntegration((integrationOptions: Partial getRouterFromApp(app) : captureRouterFromVue(Vue!); + setRouteProvider(createVueRouteProvider(getRouter), client); + } + if (options.app) { const apps = Array.isArray(options.app) ? options.app : [options.app]; apps.forEach(app => vueInit(app, options)); diff --git a/packages/vue/src/routeProvider.ts b/packages/vue/src/routeProvider.ts new file mode 100644 index 000000000000..7b9e6faf4a30 --- /dev/null +++ b/packages/vue/src/routeProvider.ts @@ -0,0 +1,89 @@ +import type { RouteProvider } from '@sentry/browser'; +import { createUrlRouteProvider } from '@sentry/browser'; +import type { Route } from './router'; +import type { Vue } from './types'; + +// Vue Router 3 resolves to `{ route }`, Vue Router 4+ returns the route itself. +type ResolvedLocation = Route | { route: Route }; + +type RouteUrl = Parameters[0]; + +interface InstalledRouter { + resolve?: (to: string) => ResolvedLocation; + /** Vue Router 4+ */ + options?: { history?: { base?: string } }; + /** Vue Router 3 */ + mode?: string; + history?: { base?: string }; +} + +interface AppWithRouter { + config?: { globalProperties?: { $router?: InstalledRouter } }; +} + +/** + * Builds a route provider from a `vue-router` instance, however the SDK got hold of one. + * + * The router is looked up per call rather than captured once, because `app.use(router)` may run + * either side of `Sentry.init()` and only the app itself is guaranteed to exist by then. + */ +export function createVueRouteProvider(getRouter: () => InstalledRouter | undefined): RouteProvider { + return createUrlRouteProvider(url => { + const router = getRouter(); + const resolved = router?.resolve?.(getRouterLocation(router, url)); + if (!resolved) { + return undefined; + } + + const route = 'matched' in resolved ? resolved : resolved.route; + + // Always the matched path, never `route.name`. Callers set `url.template` from this, and a route + // name is an identifier rather than a template. + return route.matched[route.matched.length - 1]?.path; + }); +} + +/** + * Reads the router `vue-router` installed onto a Vue app. + */ +export function getRouterFromApp(app: Vue | Vue[] | undefined): InstalledRouter | undefined { + const firstApp = (Array.isArray(app) ? app[0] : app) as AppWithRouter | undefined; + + return firstApp?.config?.globalProperties?.$router; +} + +/** + * Vue 2 only exposes the router on instances, not on the constructor passed to `init`, so this picks it + * up from the root instance (`new Vue({ router })`) as it is created. + */ +export function captureRouterFromVue(Vue: Vue): () => InstalledRouter | undefined { + let router: InstalledRouter | undefined; + Vue.mixin({ + beforeCreate(this: { $options: { router?: InstalledRouter } }) { + router = router || this.$options.router; + }, + }); + + return () => router; +} + +/** + * `resolve` matches the router's own location rather than the browser's: the hash in hash mode, and + * the path without the router's base otherwise. Mirrors vue-router's `createCurrentLocation`. + */ +function getRouterLocation(router: InstalledRouter, { pathname, search, hash }: RouteUrl): string { + const base = router.options?.history?.base ?? router.history?.base ?? ''; + const hashPos = base.indexOf('#'); + + if (hashPos > -1 || router.mode === 'hash') { + const hashBase = hashPos > -1 ? base.slice(hashPos) : '#'; + const path = hash.slice(hash.startsWith(hashBase) ? hashBase.length : 1); + + return path.startsWith('/') ? path : `/${path}`; + } + + const path = + base && pathname.toLowerCase().startsWith(base.toLowerCase()) ? pathname.slice(base.length) || '/' : pathname; + + return `${path}${search}${hash}`; +} diff --git a/packages/vue/test/integration/mixinRegistration.test.ts b/packages/vue/test/integration/mixinRegistration.test.ts index 8b5eb9de4048..90abef41e74b 100644 --- a/packages/vue/test/integration/mixinRegistration.test.ts +++ b/packages/vue/test/integration/mixinRegistration.test.ts @@ -160,7 +160,8 @@ describe('tracing mixin registration', () => { it('registers the tracing mixin on the constructor passed as `Vue` (Vue 2 setup)', ({ app, initSentry }) => { initSentry({ sdk: { app: undefined, Vue: app } }); - expect(getRegisteredMixins(app)).toHaveLength(1); + // The tracing mixin, plus the one that picks up the router for the route provider. + expect(getRegisteredMixins(app)).toHaveLength(2); }); }); diff --git a/packages/vue/test/routeProvider.test.ts b/packages/vue/test/routeProvider.test.ts new file mode 100644 index 000000000000..cc641d64edc4 --- /dev/null +++ b/packages/vue/test/routeProvider.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import { createVueRouteProvider, getRouterFromApp } from '../src/routeProvider'; +import type { Route } from '../src/router'; +import type { Vue } from '../src/types'; + +function makeRoute(overrides: Partial = {}): Route { + return { path: '/users/42', query: {}, params: {}, matched: [{ path: '/users/:id' }], ...overrides }; +} + +/** Vue Router 4+ returns the route itself. Only `/users/42` matches, so a wrong location resolves to nothing. */ +const v4Router = (route: Route | undefined, base = '') => ({ + options: { history: { base } }, + resolve: (to: string) => (to.split('?')[0] === '/users/42' ? route : makeRoute({ matched: [] })) as Route, +}); +/** Vue Router 3 wraps it in `{ route }`. */ +const v3Router = (route: Route, mode = 'history', base = '') => ({ + mode, + history: { base }, + resolve: (to: string) => ({ route: to.split('?')[0] === '/users/42' ? route : makeRoute({ matched: [] }) }), +}); + +/** A Vue 3 app with `vue-router` installed, which sets `config.globalProperties.$router`. */ +const appWithRouter = (router: unknown) => ({ config: { globalProperties: { $router: router } } }) as unknown as Vue; + +describe('getRouterFromApp', () => { + it('reads the router vue-router installed on the app', () => { + const router = v4Router(makeRoute()); + + expect(getRouterFromApp(appWithRouter(router))).toBe(router); + }); + + it('reads from the first app when several were passed', () => { + const router = v4Router(makeRoute()); + + expect(getRouterFromApp([appWithRouter(router), appWithRouter(undefined)])).toBe(router); + }); + + it('returns undefined when no router is installed yet', () => { + expect(getRouterFromApp(appWithRouter(undefined))).toBeUndefined(); + expect(getRouterFromApp(undefined)).toBeUndefined(); + }); +}); + +describe('createVueRouteProvider', () => { + it('resolves the matched path for Vue Router 4+', () => { + const provider = createVueRouteProvider(() => v4Router(makeRoute())); + + expect(provider.resolveRoute(new URL('https://example.com/users/42?tab=1'))).toBe('/users/:id'); + }); + + it('unwraps the `{ route }` shape Vue Router 3 resolves to', () => { + const provider = createVueRouteProvider(() => v3Router(makeRoute())); + + expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBe('/users/:id'); + }); + + it('returns the matched path even for a named route, since a name is not a template', () => { + const provider = createVueRouteProvider(() => v4Router(makeRoute({ name: 'UserProfile' }))); + + expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBe('/users/:id'); + }); + + it('picks the router up late, since `app.use(router)` may run after `Sentry.init`', () => { + let router: ReturnType | undefined; + const provider = createVueRouteProvider(() => router); + + expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBeUndefined(); + + router = v4Router(makeRoute()); + expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBe('/users/:id'); + }); + + it('returns undefined when nothing matched', () => { + const provider = createVueRouteProvider(() => v4Router(makeRoute())); + + expect(provider.resolveRoute(new URL('https://example.com/nope'))).toBeUndefined(); + }); + + describe('Vue Router 4+ history base', () => { + it('strips a non-root base', () => { + const provider = createVueRouteProvider(() => v4Router(makeRoute(), '/app')); + + expect(provider.resolveRoute(new URL('https://example.com/app/users/42'))).toBe('/users/:id'); + }); + + it('resolves from the hash with hash history', () => { + const provider = createVueRouteProvider(() => v4Router(makeRoute(), '/#')); + + expect(provider.resolveRoute(new URL('https://example.com/#/users/42?tab=1'))).toBe('/users/:id'); + expect(provider.resolveRoute(new URL('https://example.com/'))).toBeUndefined(); + }); + + it('resolves from the hash with hash history under a base', () => { + const provider = createVueRouteProvider(() => v4Router(makeRoute(), '/app/#')); + + expect(provider.resolveRoute(new URL('https://example.com/app/#/users/42'))).toBe('/users/:id'); + }); + }); + + describe('Vue Router 3 mode', () => { + it('strips a non-root base in history mode', () => { + const provider = createVueRouteProvider(() => v3Router(makeRoute(), 'history', '/app')); + + expect(provider.resolveRoute(new URL('https://example.com/app/users/42'))).toBe('/users/:id'); + }); + + it('resolves from the hash in hash mode', () => { + const provider = createVueRouteProvider(() => v3Router(makeRoute(), 'hash')); + + expect(provider.resolveRoute(new URL('https://example.com/#/users/42'))).toBe('/users/:id'); + }); + }); +}); diff --git a/packages/vue/test/sdk.test.ts b/packages/vue/test/sdk.test.ts new file mode 100644 index 000000000000..4473ea1175d2 --- /dev/null +++ b/packages/vue/test/sdk.test.ts @@ -0,0 +1,63 @@ +import { getRouteProvider, resolveRoute } from '@sentry/browser'; +import { getMainCarrier } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { vueIntegration } from '../src/integration'; +import { init } from '../src/sdk'; +import type { Vue } from '../src/types'; + +const DSN = 'https://public@dsn.ingest.sentry.io/1337'; + +const router = { resolve: () => ({ matched: [{ path: '/users/:id' }] }) }; + +const app = { + config: { globalProperties: { $router: router } }, + mixin: vi.fn(), +} as unknown as Vue; + +describe('route provider registration', () => { + afterEach(() => { + vi.clearAllMocks(); + getMainCarrier().__SENTRY__ = undefined; + }); + + it('registers a route provider that reads the router off the app passed to `init`', () => { + const client = init({ dsn: DSN, app, defaultIntegrations: false, integrations: [vueIntegration()] }); + + expect(resolveRoute('https://example.com/users/42', client)).toBe('/users/:id'); + }); + + it('registers a route provider when the app is passed to `vueIntegration`', () => { + const client = init({ dsn: DSN, defaultIntegrations: false, integrations: [vueIntegration({ app })] }); + + expect(resolveRoute('https://example.com/users/42', client)).toBe('/users/:id'); + }); + + it('registers a route provider that picks the router up from the Vue 2 root instance', () => { + let beforeCreate: (this: unknown) => void = () => {}; + const Vue2 = { + config: {}, + mixin: (mixin: { beforeCreate: typeof beforeCreate }) => { + beforeCreate = mixin.beforeCreate; + }, + } as unknown as Vue; + const client = init({ dsn: DSN, Vue: Vue2, defaultIntegrations: false, integrations: [vueIntegration()] }); + + expect(resolveRoute('https://example.com/users/42', client)).toBeUndefined(); + + beforeCreate.call({ $options: { router } }); + expect(resolveRoute('https://example.com/users/42', client)).toBe('/users/:id'); + }); + + it('does not register a route provider without an app to read the router from', () => { + const client = init({ dsn: DSN, defaultIntegrations: false, integrations: [vueIntegration()] }); + + expect(getRouteProvider(client)).toBeUndefined(); + }); + + it('keeps a route provider passed by the user', () => { + const routeProvider = { resolveRoute: () => '/custom', resolveCurrentRoute: () => '/custom' }; + const client = init({ dsn: DSN, app, routeProvider, defaultIntegrations: false, integrations: [vueIntegration()] }); + + expect(getRouteProvider(client)).toBe(routeProvider); + }); +}); diff --git a/yarn.lock b/yarn.lock index 004333c9dcd0..ef776d7290d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3354,10 +3354,10 @@ dependencies: "@edge-runtime/primitives" "6.0.0" -"@effect/vitest@^4.0.0-beta.50": - version "4.0.0-beta.50" - resolved "https://registry.yarnpkg.com/@effect/vitest/-/vitest-4.0.0-beta.50.tgz#c3945b4a0206fa07160896b641445e16eb5d3214" - integrity sha512-bju/iCLZB8oHsVia1i6olo9ZntkZ5TrqmsINudFsRkZfHhu5UuTR3vjic29wykZpPXXONX1wKO0KZZCk+stcKg== +"@effect/vitest@^4.0.0": + version "4.0.0" + resolved "https://sfw.security.sentry.io/npm/@effect/vitest/-/vitest-4.0.0.tgz#e5b80cfb786a657ce41224618578e3f196f6522b" + integrity sha512-kGElCPFGtP+CgMCwV/HjJWqtE3zYo6I1+8MSA54l+EI24lPU9p2Efa+TBp1jfkg9j711KKzEqgSAy3VzTsb76Q== "@ember-data/rfc395-data@^0.0.4": version "0.0.4" @@ -5643,36 +5643,6 @@ dependencies: sparse-bitfield "^3.0.3" -"@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz#9edec61b22c3082018a79f6d1c30289ddf3d9d11" - integrity sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw== - -"@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz#33677a275204898ad8acbf62734fc4dc0b6a4855" - integrity sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw== - -"@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz#19edf7cdc2e7063ee328403c1d895a86dd28f4bb" - integrity sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg== - -"@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz#94fb0543ba2e28766c3fc439cabbe0440ae70159" - integrity sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw== - -"@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz#4a0609ab5fe44d07c9c60a11e4484d3c38bbd6e3" - integrity sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg== - -"@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz#0aa5502d547b57abfc4ac492de68e2006e417242" - integrity sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ== - "@mswjs/interceptors@^0.41.0": version "0.41.9" resolved "https://registry.yarnpkg.com/@mswjs/interceptors/-/interceptors-0.41.9.tgz#9d90bbd60d1ddc30dbcbb827a9bb2e470493530d" @@ -8018,10 +7988,10 @@ "@angular-devkit/schematics" "14.2.13" jsonc-parser "3.1.0" -"@sentry/conventions@0.24.0", "@sentry/conventions@^0.24.0": - version "0.24.0" - resolved "https://sfw.security.sentry.io/npm/@sentry/conventions/-/conventions-0.24.0.tgz#b65931682a7150ffe7914e34af908b7afb2b87ab" - integrity sha512-pmmF2mLy7A23z8gnLRG87lYu4SSPIBColtfqu9cfkh1omn/0CI3w0/AqZ/uf/VGAtM/wJMN61J58Ks/IWBCHpQ== +"@sentry/conventions@0.25.0", "@sentry/conventions@^0.25.0": + version "0.25.0" + resolved "https://sfw.security.sentry.io/npm/@sentry/conventions/-/conventions-0.25.0.tgz#62c276fe8c608e533a42f0dba2ebc58c62b97b14" + integrity sha512-7SouCAX3qx+AzLsxb6gbl3dKVWNhp2wFjdYQ/T90sGwqPPQUoZVA9YwsWwvx07hqcCiSqFW9mUAzyUftASnuhQ== "@sentry/node-cpu-profiler@^2.4.4": version "2.4.4" @@ -10016,10 +9986,10 @@ hookable "^6.0.1" unhead "2.1.17" -"@vercel/nft@^1.3.0", "@vercel/nft@^1.5.0": - version "1.5.0" - resolved "https://registry.yarnpkg.com/@vercel/nft/-/nft-1.5.0.tgz#37dbededa3023d9e44d1292b594786ab1653536f" - integrity sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA== +"@vercel/nft@^1.11.0", "@vercel/nft@^1.5.0": + version "1.11.0" + resolved "https://registry.yarnpkg.com/@vercel/nft/-/nft-1.11.0.tgz#ed2db36a90f54087f36c2d8aff9e91e289cdb3c0" + integrity sha512-m1QFg+U+3yPOnP1xSYJ73UIRxLOXdts1JOhiOiyPYqEsALgrXFFINvgUaD6R6iNvaBFAjHllBCbkfx4FuOdpaA== dependencies: "@mapbox/node-pre-gyp" "^2.0.0" "@rollup/pluginutils" "^5.1.3" @@ -10031,7 +10001,7 @@ glob "^13.0.0" graceful-fs "^4.2.9" node-gyp-build "^4.2.2" - picomatch "^4.0.2" + picomatch "^4.0.4" resolve-from "^5.0.0" "@vercel/oidc@3.2.0": @@ -13851,7 +13821,7 @@ detect-indent@^6.0.0: resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-6.0.0.tgz#0abd0f549f69fc6659a254fe96786186b6f528fd" integrity sha512-oSyFlqaTHCItVRGK5RmrmjB+CmaMOW7IaNA/kdxqhoa6d17j/5ce9O9eWXmV/KEdRwqpQA+Vqe8a8Bsybu4YnA== -detect-libc@^2.0.0, detect-libc@^2.0.1, detect-libc@^2.0.3, detect-libc@^2.0.4, detect-libc@^2.1.2: +detect-libc@^2.0.0, detect-libc@^2.0.3, detect-libc@^2.0.4, detect-libc@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== @@ -13945,9 +13915,9 @@ deterministic-object-hash@^2.0.2: base-64 "^1.0.0" devalue@^5.1.1, devalue@^5.8.1, devalue@^5.9.0: - version "5.9.2" - resolved "https://registry.yarnpkg.com/devalue/-/devalue-5.9.2.tgz#2a3a8ad21904c6a630bf7bb160acb7ca2fa1e467" - integrity sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w== + version "5.9.4" + resolved "https://registry.yarnpkg.com/devalue/-/devalue-5.9.4.tgz#f6ccb639568f50233936c8c48b92a690cfb22c34" + integrity sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg== devlop@^1.0.0, devlop@^1.1.0: version "1.1.0" @@ -14192,21 +14162,10 @@ effect@3.16.12: "@standard-schema/spec" "^1.0.0" fast-check "^3.23.1" -effect@^4.0.0-beta.50: - version "4.0.0-beta.50" - resolved "https://registry.yarnpkg.com/effect/-/effect-4.0.0-beta.50.tgz#c4fbc42adad53428242b8002390bde69b48feb0d" - integrity sha512-UsENighZms6LWDSnF/05F9JinDAewV3sGXHAt9M7+dL3VnoFZIwduFxXvmFc7QJm7iV1s7rB98hv1SD3ALA9qg== - dependencies: - "@standard-schema/spec" "^1.1.0" - fast-check "^4.6.0" - find-my-way-ts "^0.1.6" - ini "^6.0.0" - kubernetes-types "^1.30.0" - msgpackr "^1.11.9" - multipasta "^0.2.7" - toml "^4.1.1" - uuid "^13.0.0" - yaml "^2.8.3" +effect@^4.0.0: + version "4.0.0" + resolved "https://sfw.security.sentry.io/npm/effect/-/effect-4.0.0.tgz#e9bc2a8e73e49492f19a3a536876ba63080ecea7" + integrity sha512-ooc1TG5t+FfzgYnFz2ff6BBKyZ7EwBRVXC7c4RhQUAD6/TZ2gTXXMeb4WX7a19ozQo4J73/QW+S00YAIresoMQ== ejs@5.0.1: version "5.0.1" @@ -15629,13 +15588,6 @@ fast-check@^3.23.1: dependencies: pure-rand "^6.1.0" -fast-check@^4.6.0: - version "4.7.0" - resolved "https://registry.yarnpkg.com/fast-check/-/fast-check-4.7.0.tgz#36c0051b9c968965e8970e88e63eee946fe45f8f" - integrity sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ== - dependencies: - pure-rand "^8.0.0" - fast-content-type-parse@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz#5590b6c807cc598be125e6740a9fde589d2b7afb" @@ -15996,11 +15948,6 @@ find-index@^1.1.0: resolved "https://registry.yarnpkg.com/find-index/-/find-index-1.1.1.tgz#4b221f8d46b7f8bea33d8faed953f3ca7a081cbc" integrity sha512-XYKutXMrIK99YMUPf91KX5QVJoG31/OsgftD6YoTPAObfQIxM4ziA9f0J1AsqKhJmo+IeaIPP0CFopTD4bdUBw== -find-my-way-ts@^0.1.6: - version "0.1.6" - resolved "https://registry.yarnpkg.com/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz#37f7b8433d0f61e7fe7290772240b0c133b0ebf2" - integrity sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA== - find-my-way@^9.6.0: version "9.7.0" resolved "https://registry.yarnpkg.com/find-my-way/-/find-my-way-9.7.0.tgz#e33ea02bcaf4199a24eea3faa9926985dee4de58" @@ -17562,11 +17509,6 @@ ini@^2.0.0: resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA== -ini@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/ini/-/ini-6.0.0.tgz#efc7642b276f6a37d22fdf56ef50889d7146bf30" - integrity sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ== - injection-js@^2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/injection-js/-/injection-js-2.4.0.tgz#ebe8871b1a349f23294eaa751bbd8209a636e754" @@ -18697,11 +18639,6 @@ koa@^2.15.2: type-is "^1.6.16" vary "^1.1.2" -kubernetes-types@^1.30.0: - version "1.30.0" - resolved "https://registry.yarnpkg.com/kubernetes-types/-/kubernetes-types-1.30.0.tgz#f686cacb08ffc5f7e89254899c2153c723420116" - integrity sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q== - kuler@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3" @@ -20366,27 +20303,6 @@ ms@2.1.3, ms@^2.0.0, ms@^2.1.1, ms@^2.1.3: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -msgpackr-extract@^3.0.2: - version "3.0.3" - resolved "https://registry.yarnpkg.com/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz#e9d87023de39ce714872f9e9504e3c1996d61012" - integrity sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA== - dependencies: - node-gyp-build-optional-packages "5.2.2" - optionalDependencies: - "@msgpackr-extract/msgpackr-extract-darwin-arm64" "3.0.3" - "@msgpackr-extract/msgpackr-extract-darwin-x64" "3.0.3" - "@msgpackr-extract/msgpackr-extract-linux-arm" "3.0.3" - "@msgpackr-extract/msgpackr-extract-linux-arm64" "3.0.3" - "@msgpackr-extract/msgpackr-extract-linux-x64" "3.0.3" - "@msgpackr-extract/msgpackr-extract-win32-x64" "3.0.3" - -msgpackr@^1.11.9: - version "1.11.9" - resolved "https://registry.yarnpkg.com/msgpackr/-/msgpackr-1.11.9.tgz#1aa99ed379a066374ac82b62f8ad70723bbd3a59" - integrity sha512-FkoAAyyA6HM8wL882EcEyFZ9s7hVADSwG9xrVx3dxxNQAtgADTrJoEWivID82Iv1zWDsv/OtbrrcZAzGzOMdNw== - optionalDependencies: - msgpackr-extract "^3.0.2" - muggle-string@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/muggle-string/-/muggle-string-0.4.1.tgz#3b366bd43b32f809dc20659534dd30e7c8a0d328" @@ -20410,11 +20326,6 @@ multicast-dns@^7.2.5: dns-packet "^5.2.2" thunky "^1.0.2" -multipasta@^0.2.7: - version "0.2.7" - resolved "https://registry.yarnpkg.com/multipasta/-/multipasta-0.2.7.tgz#fa8fb38be65eb951fa57cad9e8e758107946eee9" - integrity sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA== - mustache@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64" @@ -20788,13 +20699,6 @@ node-forge@^1, node-forge@^1.3.1, node-forge@^1.4.0: resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.4.0.tgz#1c7b7d8bdc2d078739f58287d589d903a11b2fc2" integrity sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ== -node-gyp-build-optional-packages@5.2.2: - version "5.2.2" - resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz#522f50c2d53134d7f3a76cd7255de4ab6c96a3a4" - integrity sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw== - dependencies: - detect-libc "^2.0.1" - node-gyp-build@^4.2.2: version "4.6.0" resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.6.0.tgz#0c52e4cbf54bbd28b709820ef7b6a3c2d6209055" @@ -23358,11 +23262,6 @@ pure-rand@^6.1.0: resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.1.0.tgz#d173cf23258231976ccbdb05247c9787957604f2" integrity sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA== -pure-rand@^8.0.0: - version "8.4.0" - resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-8.4.0.tgz#1d9e26e9c0555486e08ae300d02796af8dec1cd0" - integrity sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A== - qs@^6.14.0, qs@^6.15.2: version "6.15.3" resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.3.tgz#76852132a58ed5c7c0ef67e4441b9bb5d6061b3b" @@ -26392,11 +26291,6 @@ token-types@^6.1.1: "@tokenizer/token" "^0.3.0" ieee754 "^1.2.1" -toml@^4.1.1: - version "4.3.0" - resolved "https://registry.yarnpkg.com/toml/-/toml-4.3.0.tgz#10e2ff83296ec17d8935bf02364e4969cfeaae67" - integrity sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A== - totalist@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/totalist/-/totalist-3.0.0.tgz#4ef9c58c5f095255cdc3ff2a0a55091c57a3a1bd" @@ -27310,11 +27204,6 @@ uuid@^11.1.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.0.tgz#9549028be1753bb934fc96e2bca09bb4105ae912" integrity sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A== -uuid@^13.0.0: - version "13.0.0" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-13.0.0.tgz#263dc341b19b4d755eb8fe36b78d95a6b65707e8" - integrity sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w== - uuid@^9.0.0, uuid@^9.0.1: version "9.0.1" resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" @@ -28505,7 +28394,7 @@ yallist@^5.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-5.0.0.tgz#00e2de443639ed0d78fd87de0d27469fbcffb533" integrity sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw== -yaml@2.9.0, yaml@^2.8.2, yaml@^2.8.3: +yaml@2.9.0, yaml@^2.8.2: version "2.9.0" resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.9.0.tgz#78274afd93598a1dfdd6130df6a566defcbf9aa4" integrity sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==