Welcome to Sentry's Angular 21 E2E test app
+-
+
- Visit User 123 +
- Redirect +
- Cancel +
- Error +
- Component Tracking +
From 680607c460f9dfae8cd0c5f1d496274d4460ca1b Mon Sep 17 00:00:00 2001
From: Andrei <168741329+andreiborza@users.noreply.github.com>
Date: Wed, 19 Nov 2025 11:32:17 +0100
Subject: [PATCH 01/32] chore: Add `bump_otel_instrumentations` cursor command
(#18253)
Bumping OpenTelemetry instrumentations is an important but tedious task,
all instrumentations have to be bumped in lockstep across the codebase.
That includes easy to miss dev-packages and third party instrumentations
like prisma's.
This command should make it easier to do that.
Example of a PR that was kicked off with this command:
https://github.com/getsentry/sentry-javascript/pull/18239
---
.../commands/bump_otel_instrumentations.md | 32 ++++++++++++++++++
...upgrade_opentelemetry_instrumentations.mdc | 33 +++++++++++++++++++
2 files changed, 65 insertions(+)
create mode 100644 .cursor/commands/bump_otel_instrumentations.md
create mode 100644 .cursor/rules/upgrade_opentelemetry_instrumentations.mdc
diff --git a/.cursor/commands/bump_otel_instrumentations.md b/.cursor/commands/bump_otel_instrumentations.md
new file mode 100644
index 000000000000..ff1e6cfcbcc8
--- /dev/null
+++ b/.cursor/commands/bump_otel_instrumentations.md
@@ -0,0 +1,32 @@
+# Bump OpenTelemetry instrumentations
+
+1. Ensure you're on the `develop` branch with the latest changes:
+ - If you have unsaved changes, stash them with `git stash -u`.
+ - If you're on a different branch than `develop`, check out the develop branch using `git checkout develop`.
+ - Pull the latest updates from the remote repository by running `git pull origin develop`.
+
+2. Create a new branch `bump-otel-{yyyy-mm-dd}`, e.g. `bump-otel-2025-03-03`
+
+3. Create a new empty commit with the commit message `feat(deps): Bump OpenTelemetry instrumentations`
+
+4. Push the branch and create a draft PR, note down the PR number as {PR_NUMBER}
+
+5. Create a changelog entry in `CHANGELOG.md` under
+ `- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott` with the following format:
+ `- feat(deps): Bump OpenTelemetry instrumentations ([#{PR_NUMBER}](https://github.com/getsentry/sentry-javascript/pull/{PR_NUMBER}))`
+
+6. Find the "Upgrade OpenTelemetry instrumentations" rule in `.cursor/rules/upgrade_opentelemetry_instrumentations` and
+ follow those complete instructions step by step.
+ - Create one commit per package in `packages/**` with the commit message
+ `Bump OpenTelemetry instrumentations for {SDK}`, e.g. `Bump OpenTelemetry instrumentation for @sentry/node`
+
+ - For each OpenTelemetry dependency bump, record an entry in the changelog with the format indented under the main
+ entry created in step 5: `- Bump @opentelemetry/{instrumentation} from {previous_version} to {new_version}`, e.g.
+ `- Bump @opentelemetry/instrumentation from 0.204.0 to 0.207.0` **CRITICAL**: Avoid duplicated entries, e.g. if we
+ bump @opentelemetry/instrumentation in two packages, keep a single changelog entry.
+
+7. Regenerate the yarn lockfile and run `yarn yarn-deduplicate`
+
+8. Run `yarn fix` to fix all formatting issues
+
+9. Finally update the PR description to list all dependency bumps
diff --git a/.cursor/rules/upgrade_opentelemetry_instrumentations.mdc b/.cursor/rules/upgrade_opentelemetry_instrumentations.mdc
new file mode 100644
index 000000000000..b650ae1f5041
--- /dev/null
+++ b/.cursor/rules/upgrade_opentelemetry_instrumentations.mdc
@@ -0,0 +1,33 @@
+---
+description: Use this rule if you are looking to grade OpenTelemetry instrumentations for the Sentry JavaScript SDKs
+globs: *
+alwaysApply: false
+---
+
+# Upgrading OpenTelemetry instrumentations
+
+1. For every package in packages/\*\*:
+ - When upgrading dependencies for OpenTelemetry instrumentations we need to first upgrade `@opentelemetry/instrumentation` to the latest version.
+ **CRITICAL**: `@opentelemetry/instrumentation` MUST NOT include any breaking changes.
+ Read through the changelog of `@opentelemetry/instrumentation` to figure out if breaking changes are included and fail with the reason if it does include breaking changes.
+ You can find the changelog at `https://github.com/open-telemetry/opentelemetry-js/blob/main/experimental/CHANGELOG.md`
+
+ - After successfully upgrading `@opentelemetry/instrumentation` upgrade all `@opentelemetry/instrumentation-{instrumentation}` packages, e.g. `@opentelemetry/instrumentation-pg`
+ **CRITICAL**: `@opentelemetry/instrumentation-{instrumentation}` MUST NOT include any breaking changes.
+ Read through the changelog of `@opentelemetry/instrumentation-{instrumentation}` to figure out if breaking changes are included and fail with the reason if it does including breaking changes.
+ You can find the changelogs at `https://github.com/open-telemetry/opentelemetry-js-contrib/blob/main/packages/instrumentation-{instrumentation}/CHANGELOG.md`.
+
+ - Finally, upgrade third party instrumentations to their latest versions, these are currently:
+ - @prisma/instrumentation
+
+ **CRITICAL**: Upgrades to third party instrumentations MUST NOT include breaking changes.
+ Read through the changelog of each third party instrumentation to figure out if breaking changes are included and fail with the reason if it does include breaking changes.
+
+2. For packages and apps in dev-packages/\*\*:
+ - If an app depends on `@opentelemetry/instrumentation` >= 0.200.x upgrade it to the latest version.
+ **CRITICAL**: `@opentelemetry/instrumentation` MUST NOT include any breaking changes.
+
+ - If an app depends on `@opentelemetry/instrumentation-http` >= 0.200.x upgrade it to the latest version.
+ **CRITICAL**: `@opentelemetry/instrumentation-http` MUST NOT include any breaking changes.
+
+3. Generate a new yarn lock file.
From bee20fb0eaacab58d81c995e1509bce6d46b23a8 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Wed, 19 Nov 2025 12:11:41 +0100
Subject: [PATCH 02/32] ci(deps): bump actions/upload-artifact from 4 to 5
(#18075)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps
[actions/upload-artifact](https://github.com/actions/upload-artifact)
from 4 to 5.
Sourced from actions/upload-artifact's
releases. BREAKING CHANGE: this update supports Node
Full Changelog: https://github.com/actions/upload-artifact/compare/v4...v5.0.0 Full Changelog: https://github.com/actions/upload-artifact/compare/v4...v4.6.2 Full Changelog: https://github.com/actions/upload-artifact/compare/v4...v4.6.1 Full Changelog: https://github.com/actions/upload-artifact/compare/v4...v4.6.0 ... (truncated) Sourced from actions/setup-node's
releases. Breaking Changes Dependency Upgrades Full Changelog: https://github.com/actions/setup-node/compare/v5...v6.0.0 This update, introduces automatic caching when a valid
Make sure your runner is on version v2.327.1 or later to ensure
compatibility with this release. See
Release Notes Full Changelog: https://github.com/actions/setup-node/compare/v4...v5.0.0 ... (truncated) Sourced from github/codeql-action's
releases. See the releases
page for the relevant changes to the CodeQL CLI and language
packs. No user facing changes. See the full CHANGELOG.md
for more information. See the releases
page for the relevant changes to the CodeQL CLI and language
packs. See the full CHANGELOG.md
for more information. See the releases
page for the relevant changes to the CodeQL CLI and language
packs. See the full CHANGELOG.md
for more information. See the releases
page for the relevant changes to the CodeQL CLI and language
packs. See the full CHANGELOG.md
for more information. See the releases
page for the relevant changes to the CodeQL CLI and language
packs. ... (truncated) Sourced from github/codeql-action's
changelog. No user facing changes. No user facing changes. No user facing changes. ... (truncated) Sourced from actions/create-github-app-token's
releases. Sourced from astro's
releases. #14786
#14783
#14791
In order to allow data URIs for remote images, you will need to
update your export default defineConfig({
images: {
remotePatterns: [
{
protocol: 'data',
},
],
},
});
Release notes
v5.0.0
What's Changed
v24.x. This is not a breaking change per-se but we're
treating it as such.
@GhadimiR in actions/upload-artifact#681@nebuk89 in actions/upload-artifact#712@danwkennedy in
actions/upload-artifact#727@patrikpolyak
in actions/upload-artifact#725@actions/artifact to v4.0.0v5.0.0 by @danwkennedy in
actions/upload-artifact#734New Contributors
@GhadimiR
made their first contribution in actions/upload-artifact#681@nebuk89 made
their first contribution in actions/upload-artifact#712@danwkennedy
made their first contribution in actions/upload-artifact#727@patrikpolyak
made their first contribution in actions/upload-artifact#725v4.6.2
What's Changed
@salmanmkc in actions/upload-artifact#685New Contributors
@salmanmkc
made their first contribution in actions/upload-artifact#685v4.6.1
What's Changed
@yacaovsnc in actions/upload-artifact#673v4.6.0
What's Changed
@yacaovsnc in actions/upload-artifact#662v4.5.0
What's Changed
Node.js version in action by @hamirmahal in actions/upload-artifact#578artifact-digest output by @bdehamer in actions/upload-artifact#656New Contributors
@hamirmahal made
their first contribution in actions/upload-artifact#578Commits
330a01c
Merge pull request #734
from actions/danwkennedy/prepare-5.0.003f2824
Update github.dep.yml905a1ec
Prepare v5.0.02d9f9cd
Merge pull request #725
from patrikpolyak/patch-19687587
Merge branch 'main' into patch-12848b2c
Merge pull request #727
from danwkennedy/patch-19b51177
Spell out the first use of GHEScd231ca
Update GHES guidance to include reference to Node 20 versionde65e23
Merge pull request #712
from actions/nebuk89-patch-18747d8c
Update README.md
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show Release notes
v6.0.0
What's Changed
@priyagupta108
in actions/setup-node#1374
@dependabot[bot]
in #1336@dependabot[bot]
in #1334@dependabot[bot]
in #1362v5.0.0
What's Changed
Breaking Changes
@priya-kinthali
in actions/setup-node#1348packageManager field is present in your
package.json. This aims to improve workflow performance and
make dependency management more seamless.
To disable this automatic caching, set package-manager-cache:
falsesteps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
package-manager-cache: false
@salmanmkc in actions/setup-node#1325Dependency Upgrades
@octokit/request-error and
@actions/github by @dependabot[bot]
in actions/setup-node#1227@dependabot[bot]
in actions/setup-node#1273@dependabot[bot]
in actions/setup-node#1295@gowridurgad in
actions/setup-node#1332@dependabot[bot]
in actions/setup-node#1345New Contributors
@priya-kinthali
made their first contribution in actions/setup-node#1348@salmanmkc
made their first contribution in actions/setup-node#1325v4.4.0
Commits
2028fbc
Limit automatic caching to npm, update workflows and documentation (#1374)1342781
Bump actions/publish-action from 0.3.0 to 0.4.0 (#1362)89d709d
Bump prettier from 2.8.8 to 3.6.2 (#1334)cd2651c
Bump ts-jest from 29.1.2 to 29.4.1 (#1336)a0853c2
Bump actions/checkout from 4 to 5 (#1345)b7234cc
Upgrade action to use node24 (#1325)d7a1131
Enhance caching in setup-node with automatic package manager detection
(#1348)5e2628c
Bumps form-data (#1332)65becef
Bump undici from 5.28.5 to 5.29.0 (#1295)7e24a65
Bump uuid from 9.0.1 to 11.1.0 (#1273)
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show Release notes
v3.31.2
CodeQL Action Changelog
3.31.2 - 30 Oct 2025
v3.31.1
CodeQL Action Changelog
3.31.1 - 30 Oct 2025
add-snippets input has been removed from the
analyze action. This input has been deprecated since CodeQL
Action 3.26.4 in August 2024 when this removal was announced.v3.31.0
CodeQL Action Changelog
3.31.0 - 24 Oct 2025
analyze or
upload-sarif actions, the CodeQL Action automatically
performs post-processing steps to prepare the data for the upload.
Previously, these post-processing steps were only performed before an
upload took place. We are now changing this so that the post-processing
steps will always be performed, even when the SARIF files are not
uploaded. This does not change anything for the
upload-sarif action. For analyze, this may
affect Advanced Setup for CodeQL users who specify a value other than
always for the upload input. #3222v3.30.9
CodeQL Action Changelog
3.30.9 - 17 Oct 2025
setup-codeql action has been added
which is similar to init, except it only installs the
CodeQL CLI and does not initialize a database. Do not use this in
production as it is part of an internal experiment and subject to change
at any time. #3204v3.30.8
CodeQL Action Changelog
Changelog
4.31.2 - 30 Oct 2025
4.31.1 - 30 Oct 2025
add-snippets input has been removed from the
analyze action. This input has been deprecated since CodeQL
Action 3.26.4 in August 2024 when this removal was announced.4.31.0 - 24 Oct 2025
analyze or
upload-sarif actions, the CodeQL Action automatically
performs post-processing steps to prepare the data for the upload.
Previously, these post-processing steps were only performed before an
upload took place. We are now changing this so that the post-processing
steps will always be performed, even when the SARIF files are not
uploaded. This does not change anything for the
upload-sarif action. For analyze, this may
affect Advanced Setup for CodeQL users who specify a value other than
always for the upload input. #32224.30.9 - 17 Oct 2025
setup-codeql action has been added
which is similar to init, except it only installs the
CodeQL CLI and does not initialize a database. Do not use this in
production as it is part of an internal experiment and subject to change
at any time. #32044.30.8 - 10 Oct 2025
4.30.7 - 06 Oct 2025
3.30.6 - 02 Oct 2025
3.30.5 - 26 Sep 2025
3.30.4 with
upload-sarif which resulted in files without a
.sarif extension not getting uploaded. #31603.30.4 - 25 Sep 2025
codeql-action/init step if different versions of the CodeQL
Action are detected in the workflow file. Additionally, an error will
now be thrown by the other CodeQL Action steps if they load a
configuration file that was generated by a different version of the
codeql-action/init step. #3099
and #3100tools: nightly to the init action. In general,
the nightly bundle is unstable and we only recommend running it when
directed by GitHub staff. #31303.30.3 - 10 Sep 2025
3.30.2 - 09 Sep 2025
quality-queries input that was added
in 3.29.2 as part of an internal experiment is now
deprecated and will be removed in an upcoming version of the CodeQL
Action. It has been superseded by a new analysis-kinds
input, which is part of the same internal experiment. Do not use this in
production as it is subject to change at any time. #3064Commits
74c8748
Update analyze/action.yml34c50c1
Merge pull request #3251
from github/mbg/user-error/enablement4ae68af
Warn if the add-snippets input is used52a7bd7
Check for 403 status194ba0e
Make error message tests less brittle53acf0b
Turn enablement errors into configuration errorsac9aeee
Merge pull request #3249
from github/henrymercer/api-loggingd49e837
Merge branch 'main' into henrymercer/api-logging3d988b2
Pass minimal copy of core8cc18ac
Merge pull request #3250
from github/henrymercer/prefer-fs-delete
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show Release notes
v2.1.4
2.1.4
(2025-09-13)
Bug Fixes
v2.1.3
2.1.3
(2025-09-13)
Bug Fixes
v2.1.2
2.1.2
(2025-09-12)
Bug Fixes
Commits
6701853
build(release): 2.1.4 [skip ci]bef1eaf
fix(deps): bump @octokit/auth-app from 7.2.1 to 8.0.1 (#257)1526738
build(release): 2.1.3 [skip ci]f3d5ec2
fix(deps): bump undici from 7.8.0 to 7.10.0 in the
production-dependencies gr...def152b
build(release): 2.1.2 [skip ci]5d7307b
fix(deps): bump @octokit/request from 9.2.3 to 10.0.2 (#256)525760a
build(deps): bump stefanzweifel/git-auto-commit-action from 5.2.0 to
6.0.1 (#...8ab05a8
Add beta branch support for releases (#282)d00315e
build(deps): bump actions/checkout from 4 to 5 (#279)fcc6c28
build(deps-dev): bump dotenv from 16.5.0 to 17.2.1 (#269)
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
You can trigger a rebase of this PR by commenting `@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show Release notes
astro@5.15.9
Patch Changes
758a891
Thanks @mef! - Add
handling of invalid encrypted props and slots in server islands.504958f
Thanks @florian-lefebvre!
- Improves the experimental Fonts API build log to show the number of
downloaded files. This can help spotting excessive downloading because
of misconfiguration9e9c528
Thanks @Princesseuh! -
Changes the remote protocol checks for images to require explicit
authorization in order to use data URIs.astro.config.mjs file to include the following
configuration:// astro.config.mjs
import { defineConfig } from 'astro/config';
#14787
0f75f6b
Thanks @matthewp! - Fixes
wildcard hostname pattern matching to correctly reject hostnames without
dots
Previously, hostnames like localhost or other
single-part names would incorrectly match patterns like
*.example.com. The wildcard matching logic has been
corrected to ensure that only valid subdomains matching the pattern are
accepted.
#14776
3537876
Thanks @ktym4a! -
Fixes the behavior of passthroughImageService so it does
not generate webp.
#14772
00c579a
Thanks @matthewp! -
Improves the security of Server Islands slots by encrypting them before
transmission to the browser, matching the security model used for props.
This improves the integrity of slot content and prevents injection
attacks, even when component templates don't explicitly support
slots.
Slots continue to work as expected for normal usage—this change has no breaking changes for legitimate requests.
#14771
6f80081
Thanks @matthewp! - Fix
middleware pathname matching by normalizing URL-encoded paths
Middleware now receives normalized pathname values, ensuring that
encoded paths like /%61dmin are properly decoded to
/admin before middleware checks. This prevents potential
security issues where middleware checks might be bypassed through URL
encoding.
... (truncated)
Sourced from astro's changelog.
5.15.9
Patch Changes
#14786
758a891Thanks@mef! - Add handling of invalid encrypted props and slots in server islands.#14783
504958fThanks@florian-lefebvre! - Improves the experimental Fonts API build log to show the number of downloaded files. This can help spotting excessive downloading because of misconfiguration#14791
9e9c528Thanks@Princesseuh! - Changes the remote protocol checks for images to require explicit authorization in order to use data URIs.In order to allow data URIs for remote images, you will need to update your
astro.config.mjsfile to include the following configuration:// astro.config.mjs import { defineConfig } from 'astro/config';export default defineConfig({ images: { remotePatterns: [ { protocol: 'data', }, ], }, });
#14787
0f75f6bThanks@matthewp! - Fixes wildcard hostname pattern matching to correctly reject hostnames without dotsPreviously, hostnames like
localhostor other single-part names would incorrectly match patterns like*.example.com. The wildcard matching logic has been corrected to ensure that only valid subdomains matching the pattern are accepted.#14776
3537876Thanks@ktym4a! - Fixes the behavior ofpassthroughImageServiceso it does not generate webp.5.15.8
Patch Changes
#14772
00c579aThanks@matthewp! - Improves the security of Server Islands slots by encrypting them before transmission to the browser, matching the security model used for props. This improves the integrity of slot content and prevents injection attacks, even when component templates don't explicitly support slots.Slots continue to work as expected for normal usage—this change has no breaking changes for legitimate requests.
#14771
6f80081Thanks@matthewp! - Fix middleware pathname matching by normalizing URL-encoded pathsMiddleware now receives normalized pathname values, ensuring that encoded paths like
/%61dminare properly decoded to/adminbefore middleware checks. This prevents potential security issues where middleware checks might be bypassed through URL encoding.5.15.7
... (truncated)
7a07f02
[ci] release (#14788)8cf3f05
[ci] format758a891
fix(astro): handle invalid encrypted props in server island (#14786)3537876
fix: passthroughImageService generate webp (#14776)048e4dc
[ci] format9e9c528
fix: require explicit authorization to use data urls (#14791)0f75f6b
Fix wildcard hostname matching to reject hostnames without dots (#14787)504958f
feat(fonts): log number of downloaded files (#14783)24e28d2
fix(deps): update astro dependencies (#14779)60af4d0
[ci] release (#14773)This version was pushed to npm by [GitHub Actions](https://www.npmjs.com/~GitHub Actions), a new releaser for astro since your current version.
Sourced from hono's releases.
v4.10.3
Securiy Fix
A security issue in the CORS middleware has been fixed. In some cases, a request header could affect the Vary response header. Please update to the latest version if you are using the CORS middleware.
What's Changed
- fix(aws-lambda): serve microsoft office files as binary in lambda handler by
@matthiasfeistin honojs/hono#4469- fix(request-id): validation accepts
=by@ryuappin honojs/hono#4478- refactor(jwt): reduce the size of the code generated by minification by
@usualomain honojs/hono#4480New Contributors
@matthiasfeistmade their first contribution in honojs/hono#4469Full Changelog: https://github.com/honojs/hono/compare/v4.10.2...v4.10.3
v4.10.2
Security hardening improvement
If you are using JWT middleware, please read the following and consider applying the configuration.
Improper Authorization in Hono (JWT Audience Validation)
Hono’s JWT authentication middleware did not validate the aud (Audience) claim by default. As a result, applications using the middleware without an explicit audience check could accept tokens intended for other audiences, leading to potential cross-service access (token mix-up).
The issue is addressed by adding a new
verification.audconfiguration option to allow RFC 7519–compliant audience validation. This change is classified as a security hardening improvement, but the lack of validation can still be considered a vulnerability in deployments that rely on default JWT verification.Recommended secure configuration
You can enable RFC 7519–compliant audience validation using the new
verification.audoption:import { Hono } from 'hono' import { jwt } from 'hono/jwt'const app = new Hono()
app.use(
'/api/*',
jwt({
secret: 'my-secret',
verification: {
// Require this API to only accept tokens with aud = 'service-a'
aud: 'service-a',
},
})
)
What's Changed
- tests: Fix test case of handlers without a path by
@IAmSSHin honojs/hono#4472
... (truncated)
fcefd50
4.10.395ae4d3
refactor(jwt): reduce the size of the code generated by minification (#4480)d9b8b4b
Merge commit from fork5216117
fix(request-id): validation accepts = (#4478)253ec28
fix(aws-lambda): serve microsoft office files as binary in lambda
handler (#4...0c6455d
4.10.245ba3bf
Merge commit from fork4cbad8b
tests: Fix test case of handlers without a path (#4472)db764c2
4.10.18774bf9
fix(types): cannot .use non-return mw from
createMiddleware (#4465)Sourced from @sentry/cli's
releases.
2.58.2
Improvements
- Added validation for the
sentry-cli build uploadcommand's--head-shaand--base-shaarguments (#2945). The CLI now validates that these are valid SHA1 sums. Passing an empty string is also allowed; this prevents the default values from being used, causing the values to instead be unset.Fixes
- Fixed a bug where providing empty-string values for the
sentry-cli build uploadcommand's--vcs-provider,--head-repo-name,--head-ref,--base-ref, and--base-repo-namearguments resulted in 400 errors (#2946). Now, setting these to empty strings instead explicitly clears the default value we would set otherwise, as expected.2.58.1
Deprecations
- Deprecated API key authentication (#2934, #2937). Users who are still using API keys to authenticate Sentry CLI should generate and use an Auth Token instead.
Improvements
- The
sentry-cli debug-files bundle-jvmno longer makes any HTTP requests to Sentry, meaning auth tokens are no longer needed, and the command can be run offline (#2926).Fixes
- Skip setting
base_shaandbase_refwhen they equalhead_shaduring auto-inference, since comparing a commit to itself provides no meaningful baseline (#2924).- Improved error message when supplying a non-existent organization to
sentry-cli sourcemaps upload. The error now correctly indicates the organization doesn't exist, rather than incorrectly suggesting the Sentry server lacks artifact bundle support (#2931).2.58.0
New Features
- Removed experimental status from the
sentry-cli build uploadcommands (#2899, #2905). At the time of this release, build uploads are still in closed beta on the server side, so most customers cannot use this functionality quite yet.- Added CLI version metadata to build upload archives (#2890).
Deprecations
- Deprecated the
upload-proguardsubcommand's--platformflag (#2863). This flag was a no-op for some time, so we will remove it in the next major.- Deprecated the
upload-proguardsubcommand's--android-manifestflag (#2891). This flag was a no-op for some time, so we will remove it in the next major.- Deprecated the
sentry-cli sourcemaps uploadcommand's--no-dedupeflag (#2913). The flag was no longer relevant for sourcemap uploads to modern Sentry servers and was made a no-op.Fixes
- Fixed autofilled git base metadata (
--base-ref,--base-sha) when using thebuild uploadsubcommand in git repos. Previously this worked only in the context of GitHub workflows (#2897, #2898).Performance
- Slightly sped up the
sentry-cli sourcemaps uploadcommand by eliminating an HTTP request to the Sentry server, which was not required in most cases (#2913).2.57.0
New Features
- (JS API) Add
projectsfield toSentryCliUploadSourceMapsOptions(#2856)Deprecations
... (truncated)
Sourced from @sentry/cli's
changelog.
2.58.2
Improvements
- Added validation for the
sentry-cli build uploadcommand's--head-shaand--base-shaarguments (#2945). The CLI now validates that these are valid SHA1 sums. Passing an empty string is also allowed; this prevents the default values from being used, causing the values to instead be unset.Fixes
- Fixed a bug where providing empty-string values for the
sentry-cli build uploadcommand's--vcs-provider,--head-repo-name,--head-ref,--base-ref, and--base-repo-namearguments resulted in 400 errors (#2946). Now, setting these to empty strings instead explicitly clears the default value we would set otherwise, as expected.2.58.1
Deprecations
- Deprecated API key authentication (#2934, #2937). Users who are still using API keys to authenticate Sentry CLI should generate and use an Auth Token instead.
Improvements
- The
sentry-cli debug-files bundle-jvmno longer makes any HTTP requests to Sentry, meaning auth tokens are no longer needed, and the command can be run offline (#2926).Fixes
- Skip setting
base_shaandbase_refwhen they equalhead_shaduring auto-inference, since comparing a commit to itself provides no meaningful baseline (#2924).- Improved error message when supplying a non-existent organization to
sentry-cli sourcemaps upload. The error now correctly indicates the organization doesn't exist, rather than incorrectly suggesting the Sentry server lacks artifact bundle support (#2931).2.58.0
New Features
- Removed experimental status from the
sentry-cli build uploadcommands (#2899, #2905). At the time of this release, build uploads are still in closed beta on the server side, so most customers cannot use this functionality quite yet.- Added CLI version metadata to build upload archives (#2890).
Deprecations
- Deprecated the
upload-proguardsubcommand's--platformflag (#2863). This flag was a no-op for some time, so we will remove it in the next major.- Deprecated the
upload-proguardsubcommand's--android-manifestflag (#2891). This flag was a no-op for some time, so we will remove it in the next major.- Deprecated the
sentry-cli sourcemaps uploadcommand's--no-dedupeflag (#2913). The flag was no longer relevant for sourcemap uploads to modern Sentry servers and was made a no-op.Fixes
- Fixed autofilled git base metadata (
--base-ref,--base-sha) when using thebuild uploadsubcommand in git repos. Previously this worked only in the context of GitHub workflows (#2897, #2898).Performance
- Slightly sped up the
sentry-cli sourcemaps uploadcommand by eliminating an HTTP request to the Sentry server, which was not required in most cases (#2913).Internal changes
- Migrated JavaScript wrapper to TypeScript for better type safety (#2910)
... (truncated)
b8965a3
release: 2.58.2f99509f
fix(build): Allow clearing string arguments to build upload
(#2946)a2cef20
ref(build): Add client-side validation for SHA fields (#2945)c550aa7
ref(build): Move VcsInfo beside other build
upload API types (#2944)f303fd4
ref(build): Use VcsInfo directly in
ChunkedBuildRequest (#2943)63b187c
meta(cargo): Remove authors from Cargo.toml
(#2939)1ccff9d
build(npm): 🤖 Bump optional dependencies to 2.58.14362cf6
Merge branch 'release/2.58.1'b25423a
release: 2.58.17595ba9
chore(js): Deprecate apiKey field (#2937)mQ~+k=02jz)e0d9Z3>G?RGG!65?d1>9}7iG17?P*=GUV-#SbLRw)Hu{zx*azHxWk GNTWl@HeWjA?39Ia|sCi{e;!^`1Oec zb>Z|b65OM*;eC=ZLSy?_fg$&^2xI>qSLA2G*$nA3GEnp3$N-)46`|36m*sc#4%C|h zBN<2U;7k>&G_wL4=Ve5z`ubVD&*Hxi)r@{4RCDw7U_D`lbC(9&pG5C*z#W>8>HU)h z!h3g?2UL&sS!oY5$3?VlA0Me9W5e~V;2jds*fz^updz#AJ%G8w2V}AEE?E^=MK%Xt z__Bx1cr7+DQmuHmzn*|hh%~eEc9@m05@clWfpEFcr+06%0&dZJH&@8^&@*$qR@}o3 z@Tuuh2FsLz^zH+dN&T&?0G3I?MpmYJ;GP$J!Ez jeM#YLJ!W$}MVNb0^HfOA>5Fe~UNn%Zk(PT@~9}1d t)1UQ zU*B5K?Dl#G74qmg|2>^>0WtLX#Jz{lO4NT`NYB*(L#D|5IpXr9v&7a@YsGp3vLR7L zHYGHZg7{ie6n~2p$6Yz>=^cEg7tEgk-1YRl%-s7^cbqFb(U7&Dp78+&ut5!Tn(hER z|Gp4Ed@CnOPeAe|N>U(dB;SZ?NU^AzoD^UAH_vamp6Ws}{|mSq`^+VP1 g~2B{%N-!mWz<`)G) >V-<`9`L4?3dM%Qh6<@kba+m`JS{Ya@9Fq*m6$$ zA1%Ogc~VRH33|S9l%CNb4zM%k^EIpqY}@h{w(aBcJ9c05oiZx#SK9t->5lSI`=&l~ z+-Ic)a{FbBhXV$Xt!WRd`R#Jk-$+_Z52rS>?Vpt2IK<84|E-SBEoIw>cs=a{BlQ7O z-?{Fy_M&84&9|KM5wt~)*!~i~E=(6m8(uCO)I=)M?)&sRbzH$9Rovzd?ZEY}GqX+~ zFbEbLz`BZ49=2Yh-|<`waK-_4!7`ro@zlC|r&I4fc4oyb+m=|c8)8%tZ-z5FwhzDt zL5kB@u53`d@%nHl0Sp)Dw`(QU&>vujEn?GPEXUW!Wi<+4e%BORl&BIH+SwRcbS}X@ z01Pk|vA%OdJKAs17zSXtO55k!;%m9>1eW9LnyAX4uj7@${O6cfii`49qTNItzny5J zH&Gj`e}o}?xjQ}r?LrI%FjUd@xflT3|7LA|ka%Q3i}a8gVm <`HIWoJGH=$EGClX^C0lysQJ>UO(q&;`T#8txuoQ_{l^kEV9CAdXuU1Ghg8 zN_6hHFuy&1x 24q5-(Z7;!poYdt*`UTdrQOIQ!2O7_+AHV2hgXaEz7)>$LEdG z<8vE^T w$|YwZHZDPM!SNOAWG$?J)MdmEk{U!!$M#fp7 *Wo}jJ$Q(=8>R`Ats?e|VU?Zt7Cdh%AdnfyN3MBWw{ z$OnREvPf7%z6`#2##_7id|H%Y{vV^vWXb?5d5?a_y&t3@p9t$ncHj-NBdo&X{wrfJ zamN)VMYROYh_SvjJ=Xd!Ga?PY_$;*L=SxFte!4O6%0HEh%iZ4=gvns7IWIyJHa|hT z2;1+e)`TvbNb3-0z&DD_)Jomsg-7p_Uh`wjGnU1urmv1_oVqRg#=C?e?!7DgtqojU zWoAB($&53;TsXu^@2;8M`#z{=rPy?JqgYM0CDf4v@z=ZD|ItJ&8%_7A#K?S{wjxgd z?xA6JdJojrWpB7fr2p_MSsU4(R7=XGS0+Eg#xR=j>`H@R9 {XjwBm qAiOxOL` zt?XK-iTEOWV}f >Pz3H-s*>W z4~8C&Xq25UQ^xH6H9kY_RM1$ch+%YLF72AA7^b{~VNTG}Tj#qZltz5Q=qxR`&oIlW Nr__JTFzvMr^FKp4S3v*( literal 0 HcmV?d00001 diff --git a/dev-packages/e2e-tests/test-applications/angular-21/src/app/app.component.ts b/dev-packages/e2e-tests/test-applications/angular-21/src/app/app.component.ts new file mode 100644 index 000000000000..90cd343e9449 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/src/app/app.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [RouterOutlet], + template: ` `, +}) +export class AppComponent { + title = 'angular-21'; +} diff --git a/dev-packages/e2e-tests/test-applications/angular-21/src/app/app.config.ts b/dev-packages/e2e-tests/test-applications/angular-21/src/app/app.config.ts new file mode 100644 index 000000000000..f5cc30f3615b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/src/app/app.config.ts @@ -0,0 +1,29 @@ +import { + ApplicationConfig, + ErrorHandler, + inject, + provideAppInitializer, + provideZoneChangeDetection, +} from '@angular/core'; +import { Router, provideRouter } from '@angular/router'; + +import { TraceService, createErrorHandler } from '@sentry/angular'; +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideZoneChangeDetection({ eventCoalescing: true }), + provideRouter(routes), + { + provide: ErrorHandler, + useValue: createErrorHandler(), + }, + { + provide: TraceService, + deps: [Router], + }, + provideAppInitializer(() => { + inject(TraceService); + }), + ], +}; diff --git a/dev-packages/e2e-tests/test-applications/angular-21/src/app/app.routes.ts b/dev-packages/e2e-tests/test-applications/angular-21/src/app/app.routes.ts new file mode 100644 index 000000000000..24bf8b769051 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/src/app/app.routes.ts @@ -0,0 +1,42 @@ +import { Routes } from '@angular/router'; +import { cancelGuard } from './cancel-guard.guard'; +import { CancelComponent } from './cancel/cancel.components'; +import { ComponentTrackingComponent } from './component-tracking/component-tracking.components'; +import { HomeComponent } from './home/home.component'; +import { UserComponent } from './user/user.component'; + +export const routes: Routes = [ + { + path: 'users/:id', + component: UserComponent, + }, + { + path: 'home', + component: HomeComponent, + }, + { + path: 'cancel', + component: CancelComponent, + canActivate: [cancelGuard], + }, + { + path: 'component-tracking', + component: ComponentTrackingComponent, + }, + { + path: 'redirect1', + redirectTo: '/redirect2', + }, + { + path: 'redirect2', + redirectTo: '/redirect3', + }, + { + path: 'redirect3', + redirectTo: '/users/456', + }, + { + path: '**', + redirectTo: 'home', + }, +]; diff --git a/dev-packages/e2e-tests/test-applications/angular-21/src/app/cancel-guard.guard.ts b/dev-packages/e2e-tests/test-applications/angular-21/src/app/cancel-guard.guard.ts new file mode 100644 index 000000000000..16ec4a2ab164 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/src/app/cancel-guard.guard.ts @@ -0,0 +1,5 @@ +import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from '@angular/router'; + +export const cancelGuard: CanActivateFn = (_next: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => { + return false; +}; diff --git a/dev-packages/e2e-tests/test-applications/angular-21/src/app/cancel/cancel.components.ts b/dev-packages/e2e-tests/test-applications/angular-21/src/app/cancel/cancel.components.ts new file mode 100644 index 000000000000..b6ee1876e035 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/src/app/cancel/cancel.components.ts @@ -0,0 +1,8 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-cancel', + standalone: true, + template: ``, +}) +export class CancelComponent {} diff --git a/dev-packages/e2e-tests/test-applications/angular-21/src/app/component-tracking/component-tracking.components.ts b/dev-packages/e2e-tests/test-applications/angular-21/src/app/component-tracking/component-tracking.components.ts new file mode 100644 index 000000000000..76bd580ecaf6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/src/app/component-tracking/component-tracking.components.ts @@ -0,0 +1,21 @@ +import { AfterViewInit, Component, OnInit } from '@angular/core'; +import { TraceClass, TraceMethod, TraceModule } from '@sentry/angular'; +import { SampleComponent } from '../sample-component/sample-component.components'; + +@Component({ + selector: 'app-component-tracking', + standalone: true, + imports: [TraceModule, SampleComponent], + template: ` + + + `, +}) +@TraceClass({ name: 'ComponentTrackingComponent' }) +export class ComponentTrackingComponent implements OnInit, AfterViewInit { + @TraceMethod({ name: 'ngOnInit' }) + ngOnInit() {} + + @TraceMethod() + ngAfterViewInit() {} +} diff --git a/dev-packages/e2e-tests/test-applications/angular-21/src/app/home/home.component.ts b/dev-packages/e2e-tests/test-applications/angular-21/src/app/home/home.component.ts new file mode 100644 index 000000000000..78b914602eb9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/src/app/home/home.component.ts @@ -0,0 +1,26 @@ +import { Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +@Component({ + selector: 'app-home', + standalone: true, + imports: [RouterLink], + template: ` + + + `, +}) +export class HomeComponent { + throwError() { + throw new Error('Error thrown from Angular 21 E2E test app'); + } +} diff --git a/dev-packages/e2e-tests/test-applications/angular-21/src/app/sample-component/sample-component.components.ts b/dev-packages/e2e-tests/test-applications/angular-21/src/app/sample-component/sample-component.components.ts new file mode 100644 index 000000000000..da09425c7565 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/src/app/sample-component/sample-component.components.ts @@ -0,0 +1,12 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'app-sample-component', + standalone: true, + template: `Welcome to Sentry's Angular 21 E2E test app
++
+ +- Visit User 123
+- Redirect
+- Cancel
+- Error
+- Component Tracking
+Component`, +}) +export class SampleComponent implements OnInit { + ngOnInit() { + console.log('SampleComponent'); + } +} diff --git a/dev-packages/e2e-tests/test-applications/angular-21/src/app/user/user.component.ts b/dev-packages/e2e-tests/test-applications/angular-21/src/app/user/user.component.ts new file mode 100644 index 000000000000..db02568d395f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/src/app/user/user.component.ts @@ -0,0 +1,25 @@ +import { AsyncPipe } from '@angular/common'; +import { Component } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Observable, map } from 'rxjs'; + +@Component({ + selector: 'app-user', + standalone: true, + imports: [AsyncPipe], + template: ` +Hello User {{ userId$ | async }}
+ + `, +}) +export class UserComponent { + public userId$: Observable; + + constructor(private route: ActivatedRoute) { + this.userId$ = this.route.paramMap.pipe(map(params => params.get('id') || 'UNKNOWN USER')); + } + + throwError() { + throw new Error('Error thrown from user page'); + } +} diff --git a/dev-packages/e2e-tests/test-applications/angular-21/src/index.html b/dev-packages/e2e-tests/test-applications/angular-21/src/index.html new file mode 100644 index 000000000000..ffc9a3f96de6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/src/index.html @@ -0,0 +1,13 @@ + + + + + Angular 21 ++ + + + + + + diff --git a/dev-packages/e2e-tests/test-applications/angular-21/src/main.ts b/dev-packages/e2e-tests/test-applications/angular-21/src/main.ts new file mode 100644 index 000000000000..a0b841afc333 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/src/main.ts @@ -0,0 +1,15 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { AppComponent } from './app/app.component'; +import { appConfig } from './app/app.config'; + +import * as Sentry from '@sentry/angular'; + +Sentry.init({ + // Cannot use process.env here, so we hardcode the DSN + dsn: 'https://3b6c388182fb435097f41d181be2b2ba@o4504321058471936.ingest.sentry.io/4504321066008576', + tracesSampleRate: 1.0, + integrations: [Sentry.browserTracingIntegration({})], + tunnel: `http://localhost:3031/`, // proxy server +}); + +bootstrapApplication(AppComponent, appConfig).catch(err => console.error(err)); diff --git a/dev-packages/e2e-tests/test-applications/angular-21/src/styles.css b/dev-packages/e2e-tests/test-applications/angular-21/src/styles.css new file mode 100644 index 000000000000..90d4ee0072ce --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/src/styles.css @@ -0,0 +1 @@ +/* You can add global styles to this file, and also import other style files */ diff --git a/dev-packages/e2e-tests/test-applications/angular-21/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/angular-21/start-event-proxy.mjs new file mode 100644 index 000000000000..2ea1a8ef918c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'angular-21', +}); diff --git a/dev-packages/e2e-tests/test-applications/angular-21/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/angular-21/tests/errors.test.ts new file mode 100644 index 000000000000..f4f219373104 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/tests/errors.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('sends an error', async ({ page }) => { + const errorPromise = waitForError('angular-21', async errorEvent => { + return !errorEvent.type; + }); + + await page.goto(`/`); + + await page.locator('#errorBtn').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from Angular 21 E2E test app', + mechanism: { + type: 'auto.function.angular.error_handler', + handled: false, + }, + }, + ], + }, + transaction: '/home/', + }); +}); + +test('assigns the correct transaction value after a navigation', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('angular-21', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const errorPromise = waitForError('angular-21', async errorEvent => { + return !errorEvent.type; + }); + + await page.goto(`/`); + await pageloadTxnPromise; + + await page.waitForTimeout(5000); + + await page.locator('#navLink').click(); + + const [_, error] = await Promise.all([page.locator('#userErrorBtn').click(), errorPromise]); + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from user page', + mechanism: { + type: 'auto.function.angular.error_handler', + handled: false, + }, + }, + ], + }, + transaction: '/users/:id/', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/angular-21/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/angular-21/tests/performance.test.ts new file mode 100644 index 000000000000..cee1f939c4c6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/tests/performance.test.ts @@ -0,0 +1,327 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +// Cannot use @sentry/angular here due to build stuff +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; + +test('sends a pageload transaction with a parameterized URL', async ({ page }) => { + const transactionPromise = waitForTransaction('angular-21', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/`); + + const rootSpan = await transactionPromise; + + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.angular', + }, + }, + transaction: '/home/', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a navigation transaction with a parameterized URL', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('angular-21', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const navigationTxnPromise = waitForTransaction('angular-21', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + await pageloadTxnPromise; + + await page.waitForTimeout(5000); + + const [_, navigationTxn] = await Promise.all([page.locator('#navLink').click(), navigationTxnPromise]); + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + }, + }, + transaction: '/users/:id/', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a navigation transaction even if the pageload span is still active', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('angular-21', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const navigationTxnPromise = waitForTransaction('angular-21', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, pageloadTxn, navigationTxn] = await Promise.all([ + page.locator('#navLink').click(), + pageloadTxnPromise, + navigationTxnPromise, + ]); + + expect(pageloadTxn).toMatchObject({ + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.angular', + }, + }, + transaction: '/home/', + transaction_info: { + source: 'route', + }, + }); + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.angular', + }, + }, + transaction: '/users/:id/', + transaction_info: { + source: 'route', + }, + }); +}); + +test('groups redirects within one navigation root span', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-21', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#redirectLink').click(), navigationTxnPromise]); + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.angular', + }, + }, + transaction: '/users/:id/', + transaction_info: { + source: 'route', + }, + }); + + const routingSpan = navigationTxn.spans?.find(span => span.op === 'ui.angular.routing'); + + expect(routingSpan).toBeDefined(); + expect(routingSpan?.description).toBe('/redirect1'); +}); + +test.describe('finish routing span', () => { + test('finishes routing span on navigation cancel', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-21', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#cancelLink').click(), navigationTxnPromise]); + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.angular', + }, + }, + transaction: '/cancel', + transaction_info: { + source: 'url', + }, + }); + + const routingSpan = navigationTxn.spans?.find(span => span.op === 'ui.angular.routing'); + + expect(routingSpan).toBeDefined(); + expect(routingSpan?.description).toBe('/cancel'); + }); + + test('finishes routing span on navigation error', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-21', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#nonExistentLink').click(), navigationTxnPromise]); + + const nonExistentRoute = '/non-existent'; + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.angular', + }, + }, + transaction: nonExistentRoute, + transaction_info: { + source: 'url', + }, + }); + + const routingSpan = navigationTxn.spans?.find(span => span.op === 'ui.angular.routing'); + + expect(routingSpan).toBeDefined(); + expect(routingSpan?.description).toBe(nonExistentRoute); + }); +}); + +test.describe('TraceDirective', () => { + test('creates a child span with the component name as span name on ngOnInit', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-21', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#componentTracking').click(), navigationTxnPromise]); + + const traceDirectiveSpans = navigationTxn.spans?.filter( + span => span?.data && span?.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.ui.angular.trace_directive', + ); + + expect(traceDirectiveSpans).toHaveLength(2); + expect(traceDirectiveSpans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.angular.init', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_directive', + }, + description: ' ', // custom component name passed to trace directive + op: 'ui.angular.init', + origin: 'auto.ui.angular.trace_directive', + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + }), + expect.objectContaining({ + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.angular.init', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_directive', + }, + description: ' ', // fallback selector name + op: 'ui.angular.init', + origin: 'auto.ui.angular.trace_directive', + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + }), + ]), + ); + }); +}); + +test.describe('TraceClass Decorator', () => { + test('adds init span for decorated class', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-21', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#componentTracking').click(), navigationTxnPromise]); + + const classDecoratorSpan = navigationTxn.spans?.find( + span => span?.data && span?.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.ui.angular.trace_class_decorator', + ); + + expect(classDecoratorSpan).toBeDefined(); + expect(classDecoratorSpan).toEqual( + expect.objectContaining({ + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.angular.init', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_class_decorator', + }, + description: ' ', + op: 'ui.angular.init', + origin: 'auto.ui.angular.trace_class_decorator', + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + }), + ); + }); +}); + +test.describe('TraceMethod Decorator', () => { + test('adds name to span description of decorated method `ngOnInit`', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-21', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#componentTracking').click(), navigationTxnPromise]); + + const ngInitSpan = navigationTxn.spans?.find(span => span.op === 'ui.angular.ngOnInit'); + + expect(ngInitSpan).toBeDefined(); + expect(ngInitSpan).toEqual( + expect.objectContaining({ + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.angular.ngOnInit', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_method_decorator', + }, + description: ' ', + op: 'ui.angular.ngOnInit', + origin: 'auto.ui.angular.trace_method_decorator', + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + }), + ); + }); + + test('adds fallback name to span description of decorated method `ngAfterViewInit`', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-21', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#componentTracking').click(), navigationTxnPromise]); + + const ngAfterViewInitSpan = navigationTxn.spans?.find(span => span.op === 'ui.angular.ngAfterViewInit'); + + expect(ngAfterViewInitSpan).toBeDefined(); + expect(ngAfterViewInitSpan).toEqual( + expect.objectContaining({ + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.angular.ngAfterViewInit', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_method_decorator', + }, + description: ' ', + op: 'ui.angular.ngAfterViewInit', + origin: 'auto.ui.angular.trace_method_decorator', + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + }), + ); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/angular-21/tsconfig.app.json b/dev-packages/e2e-tests/test-applications/angular-21/tsconfig.app.json new file mode 100644 index 000000000000..8886e903f8d0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/tsconfig.app.json @@ -0,0 +1,11 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.d.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/angular-21/tsconfig.json b/dev-packages/e2e-tests/test-applications/angular-21/tsconfig.json new file mode 100644 index 000000000000..5525117c6744 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/tsconfig.json @@ -0,0 +1,27 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "moduleResolution": "bundler", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/angular-21/tsconfig.spec.json b/dev-packages/e2e-tests/test-applications/angular-21/tsconfig.spec.json new file mode 100644 index 000000000000..e00e30e6d4fb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/tsconfig.spec.json @@ -0,0 +1,10 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": ["jasmine"] + }, + "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/packages/angular/package.json b/packages/angular/package.json index 01912cb13f79..fd378f4af2d8 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -15,9 +15,9 @@ "access": "public" }, "peerDependencies": { - "@angular/common": ">= 14.x <= 20.x", - "@angular/core": ">= 14.x <= 20.x", - "@angular/router": ">= 14.x <= 20.x", + "@angular/common": ">= 14.x <= 21.x", + "@angular/core": ">= 14.x <= 21.x", + "@angular/router": ">= 14.x <= 21.x", "rxjs": "^6.5.5 || ^7.x" }, "dependencies": { From 108b027446cfb166a7dca04e69ec9207c2bf2406 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Nov 2025 12:57:05 +0100 Subject: [PATCH 17/32] feat(deps): bump @sentry/bundler-plugin-core from 4.3.0 to 4.6.1 (#18273) --- packages/nextjs/package.json | 2 +- yarn.lock | 31 +++++++++++++++++++++++++------ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 15b6a2bf3040..9afdfec16d7b 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -80,7 +80,7 @@ "@opentelemetry/semantic-conventions": "^1.37.0", "@rollup/plugin-commonjs": "28.0.1", "@sentry-internal/browser-utils": "10.26.0", - "@sentry/bundler-plugin-core": "^4.3.0", + "@sentry/bundler-plugin-core": "^4.6.1", "@sentry/core": "10.26.0", "@sentry/node": "10.26.0", "@sentry/opentelemetry": "10.26.0", diff --git a/yarn.lock b/yarn.lock index e3f84550ba9e..8687df6cfa53 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7089,7 +7089,12 @@ resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.3.0.tgz#c5b6cbb986952596d3ad233540a90a1fd18bad80" integrity sha512-OuxqBprXRyhe8Pkfyz/4yHQJc5c3lm+TmYWSSx8u48g5yKewSQDOxkiLU5pAk3WnbLPy8XwU/PN+2BG0YFU9Nw== -"@sentry/bundler-plugin-core@4.3.0", "@sentry/bundler-plugin-core@^4.3.0": +"@sentry/babel-plugin-component-annotate@4.6.1": + version "4.6.1" + resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.6.1.tgz#94eec0293be8289daa574e18783e64d29203c236" + integrity sha512-aSIk0vgBqv7PhX6/Eov+vlI4puCE0bRXzUG5HdCsHBpAfeMkI8Hva6kSOusnzKqs8bf04hU7s3Sf0XxGTj/1AA== + +"@sentry/bundler-plugin-core@4.3.0": version "4.3.0" resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.3.0.tgz#cf302522a3e5b8a3bf727635d0c6a7bece981460" integrity sha512-dmR4DJhJ4jqVWGWppuTL2blNFqOZZnt4aLkewbD1myFG3KVfUx8CrMQWEmGjkgPOtj5TO6xH9PyTJjXC6o5tnA== @@ -7103,6 +7108,20 @@ magic-string "0.30.8" unplugin "1.0.1" +"@sentry/bundler-plugin-core@^4.6.1": + version "4.6.1" + resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.6.1.tgz#d6013e6233bf663114f581bbd3c3a380ff9311d4" + integrity sha512-WPeRbnMXm927m4Kr69NTArPfI+p5/34FHftdCRI3LFPMyhZDzz6J3wLy4hzaVUgmMf10eLzmq2HGEMvpQmdynA== + dependencies: + "@babel/core" "^7.18.5" + "@sentry/babel-plugin-component-annotate" "4.6.1" + "@sentry/cli" "^2.57.0" + dotenv "^16.3.1" + find-up "^5.0.0" + glob "^10.5.0" + magic-string "0.30.8" + unplugin "1.0.1" + "@sentry/cli-darwin@2.58.2": version "2.58.2" resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.58.2.tgz#61f6f836de8ac2e1992ccadc0368bc403f23c609" @@ -7143,7 +7162,7 @@ resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.2.tgz#b4c81a3c163344ae8b27523a0391e7f99c533f41" integrity sha512-2NAFs9UxVbRztQbgJSP5i8TB9eJQ7xraciwj/93djrSMHSEbJ0vC47TME0iifgvhlHMs5vqETOKJtfbbpQAQFA== -"@sentry/cli@^2.51.0", "@sentry/cli@^2.58.2": +"@sentry/cli@^2.51.0", "@sentry/cli@^2.57.0", "@sentry/cli@^2.58.2": version "2.58.2" resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.58.2.tgz#0d6e19a1771d27aae8b2765a6f3e96062e2c7502" integrity sha512-U4u62V4vaTWF+o40Mih8aOpQKqKUbZQt9A3LorIJwaE3tO3XFLRI70eWtW2se1Qmy0RZ74zB14nYcFNFl2t4Rw== @@ -17942,10 +17961,10 @@ glob@8.0.3: minimatch "^5.0.1" once "^1.3.0" -glob@^10.0.0, glob@^10.2.2, glob@^10.3.10, glob@^10.3.4, glob@^10.3.7, glob@^10.4.1, glob@^10.4.5: - version "10.4.5" - resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" - integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== +glob@^10.0.0, glob@^10.2.2, glob@^10.3.10, glob@^10.3.4, glob@^10.3.7, glob@^10.4.1, glob@^10.4.5, glob@^10.5.0: + version "10.5.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" + integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== dependencies: foreground-child "^3.1.0" jackspeak "^3.1.2" From edc1f09dc7f8e6a67647f8c91aa00169319be4fb Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 21 Nov 2025 14:31:26 +0100 Subject: [PATCH 18/32] test(e2e): Fix astro config in test app (#18282) https://5-0-0-beta.docs.astro.build/en/guides/upgrade-to/v5/#removed-hybrid-rendering-mode the test app was bumped to v5 from dependabot in https://github.com/getsentry/sentry-javascript/pull/18259 --------- Co-authored-by: Andrei Borza --- .../test-applications/cloudflare-astro/astro.config.mjs | 1 - .../e2e-tests/test-applications/cloudflare-astro/package.json | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-astro/astro.config.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-astro/astro.config.mjs index 36414cf24b7c..026e6e4dac7c 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-astro/astro.config.mjs +++ b/dev-packages/e2e-tests/test-applications/cloudflare-astro/astro.config.mjs @@ -6,7 +6,6 @@ const dsn = process.env.E2E_TEST_DSN; // https://astro.build/config export default defineConfig({ - output: 'hybrid', adapter: cloudflare({ imageService: 'passthrough', }), diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-astro/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-astro/package.json index 4db15edabbd7..776cf271e86e 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-astro/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-astro/package.json @@ -17,7 +17,7 @@ "test:assert": "pnpm -v" }, "dependencies": { - "@astrojs/cloudflare": "8.1.0", + "@astrojs/cloudflare": "12.6.11", "@sentry/astro": "latest || *", "astro": "5.15.9" }, From 3375de056e152cd58345a5b87b77a8a1068957bd Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 21 Nov 2025 15:42:21 +0100 Subject: [PATCH 19/32] feat(core): Add scope attribute APIs (#18165) This PR adds `scope.setAttribute`, `scope.setAttributes` and `scope.removeAttribute` methods, as specified in our [develop docs](https://develop.sentry.dev/sdk/telemetry/scopes/#setting-attributes). This intial PR only enables setting the attributes (including attributes with units) as well as the usual scope data operations (clone(), update(), clear(), getSpanData()). These attributes are not yet applied to any of the telemetry we eventually want them to apply to. I'll take care of this in a follow-up PR. closes https://github.com/getsentry/sentry-javascript/issues/18140 ref https://linear.app/getsentry/project/implement-global-attributes-api-javascript-02c3c74184fc/issues --------- Co-authored-by: Sigrid <32902192+s1gr1d@users.noreply.github.com> --- .size-limit.js | 14 +- packages/core/src/attributes.ts | 141 +++++++++++ packages/core/src/scope.ts | 96 +++++++- packages/core/test/lib/attributes.test.ts | 286 ++++++++++++++++++++++ packages/core/test/lib/scope.test.ts | 167 ++++++++++++- 5 files changed, 695 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/attributes.ts create mode 100644 packages/core/test/lib/attributes.test.ts diff --git a/.size-limit.js b/.size-limit.js index 100444907e06..6e6ee0f68303 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -38,7 +38,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '41.5 KB', + limit: '42 KB', }, { name: '@sentry/browser (incl. Tracing, Profiling)', @@ -127,7 +127,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '43.5 KB', + limit: '44 KB', }, // Vue SDK (ESM) { @@ -142,7 +142,7 @@ module.exports = [ path: 'packages/vue/build/esm/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '43.3 KB', + limit: '44 KB', }, // Svelte SDK (ESM) { @@ -163,7 +163,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing)', path: createCDNPath('bundle.tracing.min.js'), gzip: true, - limit: '42.1 KB', + limit: '42.5 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay)', @@ -183,14 +183,14 @@ module.exports = [ path: createCDNPath('bundle.min.js'), gzip: false, brotli: false, - limit: '80 KB', + limit: '82 KB', }, { name: 'CDN Bundle (incl. Tracing) - uncompressed', path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '125 KB', + limit: '127 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed', @@ -231,7 +231,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '51.1 KB', + limit: '52 KB', }, // Node SDK (ESM) { diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts new file mode 100644 index 000000000000..d979d5c4350f --- /dev/null +++ b/packages/core/src/attributes.ts @@ -0,0 +1,141 @@ +import { DEBUG_BUILD } from './debug-build'; +import type { DurationUnit, FractionUnit, InformationUnit } from './types-hoist/measurement'; +import { debug } from './utils/debug-logger'; + +export type RawAttributes = T & ValidatedAttributes ; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type RawAttribute = T extends { value: any } | { unit: any } ? AttributeObject : T; + +export type Attributes = Record ; + +export type AttributeValueType = string | number | boolean | Array | Array | Array ; + +type AttributeTypeMap = { + string: string; + integer: number; + double: number; + boolean: boolean; + 'string[]': Array ; + 'integer[]': Array ; + 'double[]': Array ; + 'boolean[]': Array ; +}; + +/* Generates a type from the AttributeTypeMap like: + | { value: string; type: 'string' } + | { value: number; type: 'integer' } + | { value: number; type: 'double' } + */ +type AttributeUnion = { + [K in keyof AttributeTypeMap]: { + value: AttributeTypeMap[K]; + type: K; + }; +}[keyof AttributeTypeMap]; + +export type TypedAttributeValue = AttributeUnion & { unit?: AttributeUnit }; + +export type AttributeObject = { + value: unknown; + unit?: AttributeUnit; +}; + +// Unfortunately, we loose type safety if we did something like Exclude +// so therefore we unionize between the three supported unit categories. +type AttributeUnit = DurationUnit | InformationUnit | FractionUnit; + +/* If an attribute has either a 'value' or 'unit' property, we use the ValidAttributeObject type. */ +export type ValidatedAttributes = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [K in keyof T]: T[K] extends { value: any } | { unit: any } ? AttributeObject : unknown; +}; + +/** + * Type-guard: The attribute object has the shape the official attribute object (value, type, unit). + * https://develop.sentry.dev/sdk/telemetry/scopes/#setting-attributes + */ +export function isAttributeObject(maybeObj: unknown): maybeObj is AttributeObject { + return ( + typeof maybeObj === 'object' && + maybeObj != null && + !Array.isArray(maybeObj) && + Object.keys(maybeObj).includes('value') + ); +} + +/** + * Converts an attribute value to a typed attribute value. + * + * Does not allow mixed arrays. In case of a mixed array, the value is stringified and the type is 'string'. + * All values besides the supported attribute types (see {@link AttributeTypeMap}) are stringified to a string attribute value. + * + * @param value - The value of the passed attribute. + * @returns The typed attribute. + */ +export function attributeValueToTypedAttributeValue(rawValue: unknown): TypedAttributeValue { + const { value, unit } = isAttributeObject(rawValue) ? rawValue : { value: rawValue, unit: undefined }; + return { ...getTypedAttributeValue(value), ...(unit && typeof unit === 'string' ? { unit } : {}) }; +} + +// Only allow string, boolean, or number types +const getPrimitiveType: ( + item: unknown, +) => keyof Pick | null = item => + typeof item === 'string' + ? 'string' + : typeof item === 'boolean' + ? 'boolean' + : typeof item === 'number' && !Number.isNaN(item) + ? Number.isInteger(item) + ? 'integer' + : 'double' + : null; + +function getTypedAttributeValue(value: unknown): TypedAttributeValue { + const primitiveType = getPrimitiveType(value); + if (primitiveType) { + // @ts-expect-error - TS complains because {@link TypedAttributeValue} is strictly typed to + // avoid setting the wrong `type` on the attribute value. + // In this case, getPrimitiveType already does the check but TS doesn't know that. + // The "clean" alternative is to return an object per `typeof value` case + // but that would require more bundle size + // Therefore, we ignore it. + return { value, type: primitiveType }; + } + + if (Array.isArray(value)) { + const coherentArrayType = value.reduce((acc: 'string' | 'boolean' | 'integer' | 'double' | null, item) => { + if (!acc || getPrimitiveType(item) !== acc) { + return null; + } + return acc; + }, getPrimitiveType(value[0])); + + if (coherentArrayType) { + return { value, type: `${coherentArrayType}[]` }; + } + } + + // Fallback: stringify the passed value + let fallbackValue = ''; + try { + fallbackValue = JSON.stringify(value) ?? String(value); + } catch { + try { + fallbackValue = String(value); + } catch { + DEBUG_BUILD && debug.warn('Failed to stringify attribute value', value); + // ignore + } + } + + // This is quite a low-quality message but we cannot safely log the original `value` + // here due to String() or JSON.stringify() potentially throwing. + DEBUG_BUILD && + debug.log(`Stringified attribute value to ${fallbackValue} because it's not a supported attribute value type`); + + return { + value: fallbackValue, + type: 'string', + }; +} diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index b23b01664431..2ec1f6480788 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -1,4 +1,5 @@ /* eslint-disable max-lines */ +import type { AttributeObject, RawAttribute, RawAttributes } from './attributes'; import type { Client } from './client'; import { DEBUG_BUILD } from './debug-build'; import { updateSession } from './session'; @@ -46,6 +47,7 @@ export interface ScopeContext { extra: Extras; contexts: Contexts; tags: { [key: string]: Primitive }; + attributes?: RawAttributes >; fingerprint: string[]; propagationContext: PropagationContext; } @@ -71,6 +73,8 @@ export interface ScopeData { breadcrumbs: Breadcrumb[]; user: User; tags: { [key: string]: Primitive }; + // TODO(v11): Make this a required field (could be subtly breaking if we did it today) + attributes?: RawAttributes >; extra: Extras; contexts: Contexts; attachments: Attachment[]; @@ -104,6 +108,9 @@ export class Scope { /** Tags */ protected _tags: { [key: string]: Primitive }; + /** Attributes */ + protected _attributes: RawAttributes >; + /** Extra */ protected _extra: Extras; @@ -155,6 +162,7 @@ export class Scope { this._attachments = []; this._user = {}; this._tags = {}; + this._attributes = {}; this._extra = {}; this._contexts = {}; this._sdkProcessingMetadata = {}; @@ -171,6 +179,7 @@ export class Scope { const newScope = new Scope(); newScope._breadcrumbs = [...this._breadcrumbs]; newScope._tags = { ...this._tags }; + newScope._attributes = { ...this._attributes }; newScope._extra = { ...this._extra }; newScope._contexts = { ...this._contexts }; if (this._contexts.flags) { @@ -294,6 +303,79 @@ export class Scope { return this.setTags({ [key]: value }); } + /** + * Sets attributes onto the scope. + * + * TODO: + * Currently, these attributes are not applied to any telemetry data but they will be in the future. + * + * @param newAttributes - The attributes to set on the scope. You can either pass in key-value pairs, or + * an object with a `value` and an optional `unit` (if applicable to your attribute). + * + * @example + * ```typescript + * scope.setAttributes({ + * is_admin: true, + * payment_selection: 'credit_card', + * clicked_products: [130, 554, 292], + * render_duration: { value: 'render_duration', unit: 'ms' }, + * }); + * ``` + */ + public setAttributes >(newAttributes: RawAttributes ): this { + this._attributes = { + ...this._attributes, + ...newAttributes, + }; + + this._notifyScopeListeners(); + return this; + } + + /** + * Sets an attribute onto the scope. + * + * TODO: + * Currently, these attributes are not applied to any telemetry data but they will be in the future. + * + * @param key - The attribute key. + * @param value - the attribute value. You can either pass in a raw value, or an attribute + * object with a `value` and an optional `unit` (if applicable to your attribute). + * + * @example + * ```typescript + * scope.setAttribute('is_admin', true); + * scope.setAttribute('clicked_products', [130, 554, 292]); + * scope.setAttribute('render_duration', { value: 'render_duration', unit: 'ms' }); + * ``` + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public setAttribute extends { value: any } | { unit: any } ? AttributeObject : unknown>( + key: string, + value: RawAttribute , + ): this { + return this.setAttributes({ [key]: value }); + } + + /** + * Removes the attribute with the given key from the scope. + * + * @param key - The attribute key. + * + * @example + * ```typescript + * scope.removeAttribute('is_admin'); + * ``` + */ + public removeAttribute(key: string): this { + if (key in this._attributes) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete this._attributes[key]; + this._notifyScopeListeners(); + } + return this; + } + /** * Set an object that will be merged into existing extra on the scope, * and will be sent as extra data with the event. @@ -409,9 +491,19 @@ export class Scope { ? (captureContext as ScopeContext) : undefined; - const { tags, extra, user, contexts, level, fingerprint = [], propagationContext } = scopeInstance || {}; + const { + tags, + attributes, + extra, + user, + contexts, + level, + fingerprint = [], + propagationContext, + } = scopeInstance || {}; this._tags = { ...this._tags, ...tags }; + this._attributes = { ...this._attributes, ...attributes }; this._extra = { ...this._extra, ...extra }; this._contexts = { ...this._contexts, ...contexts }; @@ -442,6 +534,7 @@ export class Scope { // client is not cleared here on purpose! this._breadcrumbs = []; this._tags = {}; + this._attributes = {}; this._extra = {}; this._user = {}; this._contexts = {}; @@ -528,6 +621,7 @@ export class Scope { attachments: this._attachments, contexts: this._contexts, tags: this._tags, + attributes: this._attributes, extra: this._extra, user: this._user, level: this._level, diff --git a/packages/core/test/lib/attributes.test.ts b/packages/core/test/lib/attributes.test.ts new file mode 100644 index 000000000000..99aa20d07c85 --- /dev/null +++ b/packages/core/test/lib/attributes.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it } from 'vitest'; +import { attributeValueToTypedAttributeValue, isAttributeObject } from '../../src/attributes'; + +describe('attributeValueToTypedAttributeValue', () => { + describe('primitive values', () => { + it('converts a string value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue('test'); + expect(result).toStrictEqual({ + value: 'test', + type: 'string', + }); + }); + + it('converts an interger number value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue(42); + expect(result).toStrictEqual({ + value: 42, + type: 'integer', + }); + }); + + it('converts a double number value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue(42.34); + expect(result).toStrictEqual({ + value: 42.34, + type: 'double', + }); + }); + + it('converts a boolean value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue(true); + expect(result).toStrictEqual({ + value: true, + type: 'boolean', + }); + }); + }); + + describe('arrays', () => { + it('converts an array of strings to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue(['foo', 'bar']); + expect(result).toStrictEqual({ + value: ['foo', 'bar'], + type: 'string[]', + }); + }); + + it('converts an array of integer numbers to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue([1, 2, 3]); + expect(result).toStrictEqual({ + value: [1, 2, 3], + type: 'integer[]', + }); + }); + + it('converts an array of double numbers to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue([1.1, 2.2, 3.3]); + expect(result).toStrictEqual({ + value: [1.1, 2.2, 3.3], + type: 'double[]', + }); + }); + + it('converts an array of booleans to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue([true, false, true]); + expect(result).toStrictEqual({ + value: [true, false, true], + type: 'boolean[]', + }); + }); + }); + + describe('attribute objects without units', () => { + // Note: These tests only test exemplar type and fallback behaviour (see above for more cases) + it('converts a primitive value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue({ value: 123.45 }); + expect(result).toStrictEqual({ + value: 123.45, + type: 'double', + }); + }); + + it('converts an array of primitive values to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue({ value: [true, false] }); + expect(result).toStrictEqual({ + value: [true, false], + type: 'boolean[]', + }); + }); + + it('converts an unsupported object value to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue({ value: { foo: 'bar' } }); + expect(result).toStrictEqual({ + value: '{"foo":"bar"}', + type: 'string', + }); + }); + }); + + describe('attribute objects with units', () => { + // Note: These tests only test exemplar type and fallback behaviour (see above for more cases) + it('converts a primitive value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue({ value: 123.45, unit: 'ms' }); + expect(result).toStrictEqual({ + value: 123.45, + type: 'double', + unit: 'ms', + }); + }); + + it('converts an array of primitive values to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue({ value: [true, false], unit: 'count' }); + expect(result).toStrictEqual({ + value: [true, false], + type: 'boolean[]', + unit: 'count', + }); + }); + + it('converts an unsupported object value to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue({ value: { foo: 'bar' }, unit: 'bytes' }); + expect(result).toStrictEqual({ + value: '{"foo":"bar"}', + type: 'string', + unit: 'bytes', + }); + }); + + it('extracts the value property of an object with a value property', () => { + // and ignores other properties. + // For now we're fine with this but we may reconsider in the future. + const result = attributeValueToTypedAttributeValue({ value: 'foo', unit: 'ms', bar: 'baz' }); + expect(result).toStrictEqual({ + value: 'foo', + unit: 'ms', + type: 'string', + }); + }); + }); + + describe('unsupported value types', () => { + it('stringifies mixed float and integer numbers to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue([1, 2.2, 3]); + expect(result).toStrictEqual({ + value: '[1,2.2,3]', + type: 'string', + }); + }); + + it('stringifies an array of allowed but incoherent types to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue([1, 'foo', true]); + expect(result).toStrictEqual({ + value: '[1,"foo",true]', + type: 'string', + }); + }); + + it('stringifies an array of disallowed and incoherent types to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue([null, undefined, NaN]); + expect(result).toStrictEqual({ + value: '[null,null,null]', + type: 'string', + }); + }); + + it('stringifies an object value to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue({ foo: 'bar' }); + expect(result).toStrictEqual({ + value: '{"foo":"bar"}', + type: 'string', + }); + }); + + it('stringifies a null value to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue(null); + expect(result).toStrictEqual({ + value: 'null', + type: 'string', + }); + }); + + it('stringifies an undefined value to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue(undefined); + expect(result).toStrictEqual({ + value: 'undefined', + type: 'string', + }); + }); + + it('stringifies an NaN number value to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue(NaN); + expect(result).toStrictEqual({ + value: 'null', + type: 'string', + }); + }); + + it('converts an object toString if stringification fails', () => { + const result = attributeValueToTypedAttributeValue({ + value: { + toJson: () => { + throw new Error('test'); + }, + }, + }); + expect(result).toStrictEqual({ + value: '{}', + type: 'string', + }); + }); + + it('falls back to an empty string if stringification and toString fails', () => { + const result = attributeValueToTypedAttributeValue({ + value: { + toJSON: () => { + throw new Error('test'); + }, + toString: () => { + throw new Error('test'); + }, + }, + }); + expect(result).toStrictEqual({ + value: '', + type: 'string', + }); + }); + + it('converts a function toString ', () => { + const result = attributeValueToTypedAttributeValue(() => { + return 'test'; + }); + + expect(result).toStrictEqual({ + value: '() => {\n return "test";\n }', + type: 'string', + }); + }); + + it('converts a symbol toString', () => { + const result = attributeValueToTypedAttributeValue(Symbol('test')); + expect(result).toStrictEqual({ + value: 'Symbol(test)', + type: 'string', + }); + }); + }); + + it.each([1, true, null, undefined, NaN, Symbol('test'), { foo: 'bar' }])( + 'ignores invalid (non-string) units (%s)', + unit => { + const result = attributeValueToTypedAttributeValue({ value: 'foo', unit }); + expect(result).toStrictEqual({ + value: 'foo', + type: 'string', + }); + }, + ); +}); + +describe('isAttributeObject', () => { + it.each([ + { value: 123.45, unit: 'ms' }, + { value: [true, false], unit: 'count' }, + { value: { foo: 'bar' }, unit: 'bytes' }, + { value: { value: 123.45, unit: 'ms' }, unit: 'ms' }, + { value: 1 }, + ])('returns true for a valid attribute object (%s)', obj => { + const result = isAttributeObject(obj); + expect(result).toBe(true); + }); + + it('returns true for an object with a value property', () => { + // Explicitly demonstrate this behaviour which for now we're fine with. + // We may reconsider in the future. + expect(isAttributeObject({ value: 123.45, some: 'other property' })).toBe(true); + }); + + it.each([1, true, 'test', null, undefined, NaN, Symbol('test')])( + 'returns false for an invalid attribute object (%s)', + obj => { + const result = isAttributeObject(obj); + expect(result).toBe(false); + }, + ); +}); diff --git a/packages/core/test/lib/scope.test.ts b/packages/core/test/lib/scope.test.ts index 221ac14a6fa2..339a57828e5b 100644 --- a/packages/core/test/lib/scope.test.ts +++ b/packages/core/test/lib/scope.test.ts @@ -27,6 +27,7 @@ describe('Scope', () => { attachments: [], contexts: {}, tags: {}, + attributes: {}, extra: {}, user: {}, level: undefined, @@ -42,6 +43,7 @@ describe('Scope', () => { scope.update({ tags: { foo: 'bar' }, extra: { foo2: 'bar2' }, + attributes: { attr1: { value: 'value1' } }, }); expect(scope.getScopeData()).toEqual({ @@ -51,6 +53,7 @@ describe('Scope', () => { tags: { foo: 'bar', }, + attributes: { attr1: { value: 'value1' } }, extra: { foo2: 'bar2', }, @@ -71,6 +74,7 @@ describe('Scope', () => { scope.update({ tags: { foo: 'bar' }, + attributes: { attr1: { value: 'value1', type: 'string' } }, extra: { foo2: 'bar2' }, }); @@ -85,6 +89,7 @@ describe('Scope', () => { tags: { foo: 'bar', }, + attributes: { attr1: { value: 'value1', type: 'string' } }, extra: { foo2: 'bar2', }, @@ -114,7 +119,7 @@ describe('Scope', () => { }); }); - describe('attributes modification', () => { + describe('scope data modification', () => { test('setFingerprint', () => { const scope = new Scope(); scope.setFingerprint(['abcd']); @@ -183,6 +188,159 @@ describe('Scope', () => { }); }); + describe('setAttribute', () => { + it('accepts a key-value pair', () => { + const scope = new Scope(); + + scope.setAttribute('str', 'b'); + scope.setAttribute('int', 1); + scope.setAttribute('double', 1.1); + scope.setAttribute('bool', true); + + expect(scope['_attributes']).toEqual({ + str: 'b', + bool: true, + double: 1.1, + int: 1, + }); + }); + + it('accepts an attribute value object', () => { + const scope = new Scope(); + scope.setAttribute('str', { value: 'b' }); + expect(scope['_attributes']).toEqual({ + str: { value: 'b' }, + }); + }); + + it('accepts an attribute value object with a unit', () => { + const scope = new Scope(); + scope.setAttribute('str', { value: 1, unit: 'millisecond' }); + expect(scope['_attributes']).toEqual({ + str: { value: 1, unit: 'millisecond' }, + }); + }); + + it('still accepts a custom unit but TS-errors on it', () => { + // mostly there for type checking purposes. + const scope = new Scope(); + /** @ts-expect-error we don't support custom units type-wise but we don't actively block them */ + scope.setAttribute('str', { value: 3, unit: 'inch' }); + expect(scope['_attributes']).toEqual({ + str: { value: 3, unit: 'inch' }, + }); + }); + + it('accepts an array', () => { + const scope = new Scope(); + + scope.setAttribute('strArray', ['a', 'b', 'c']); + scope.setAttribute('intArray', { value: [1, 2, 3], unit: 'millisecond' }); + + expect(scope['_attributes']).toEqual({ + strArray: ['a', 'b', 'c'], + intArray: { value: [1, 2, 3], unit: 'millisecond' }, + }); + }); + + it('notifies scope listeners once per call', () => { + const scope = new Scope(); + const listener = vi.fn(); + scope.addScopeListener(listener); + scope.setAttribute('str', 'b'); + scope.setAttribute('int', 1); + expect(listener).toHaveBeenCalledTimes(2); + }); + }); + + describe('setAttributes', () => { + it('accepts key-value pairs', () => { + const scope = new Scope(); + scope.setAttributes({ str: 'b', int: 1, double: 1.1, bool: true }); + expect(scope['_attributes']).toEqual({ + str: 'b', + int: 1, + double: 1.1, + bool: true, + }); + }); + + it('accepts attribute value objects', () => { + const scope = new Scope(); + scope.setAttributes({ str: { value: 'b' }, int: { value: 1 } }); + expect(scope['_attributes']).toEqual({ + str: { value: 'b' }, + int: { value: 1 }, + }); + }); + + it('accepts attribute value objects with units', () => { + const scope = new Scope(); + scope.setAttributes({ str: { value: 'b', unit: 'millisecond' }, int: { value: 12, unit: 'second' } }); + expect(scope['_attributes']).toEqual({ + str: { value: 'b', unit: 'millisecond' }, + int: { value: 12, unit: 'second' }, + }); + }); + + it('accepts arrays', () => { + const scope = new Scope(); + scope.setAttributes({ + strArray: ['a', 'b', 'c'], + intArray: { value: [1, 2, 3], unit: 'millisecond' }, + }); + + expect(scope['_attributes']).toEqual({ + strArray: ['a', 'b', 'c'], + intArray: { value: [1, 2, 3], unit: 'millisecond' }, + }); + }); + + it('notifies scope listeners once per call', () => { + const scope = new Scope(); + const listener = vi.fn(); + scope.addScopeListener(listener); + scope.setAttributes({ str: 'b', int: 1 }); + scope.setAttributes({ bool: true }); + expect(listener).toHaveBeenCalledTimes(2); + }); + }); + + describe('removeAttribute', () => { + it('removes an attribute', () => { + const scope = new Scope(); + scope.setAttribute('str', 'b'); + scope.setAttribute('int', 1); + scope.removeAttribute('str'); + expect(scope['_attributes']).toEqual({ int: 1 }); + }); + + it('notifies scope listeners after deletion', () => { + const scope = new Scope(); + const listener = vi.fn(); + + scope.addScopeListener(listener); + scope.setAttribute('str', { value: 'b' }); + expect(listener).toHaveBeenCalledTimes(1); + + listener.mockClear(); + + scope.removeAttribute('str'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('does nothing if the attribute does not exist', () => { + const scope = new Scope(); + const listener = vi.fn(); + + scope.addScopeListener(listener); + scope.removeAttribute('str'); + + expect(scope['_attributes']).toEqual({}); + expect(listener).not.toHaveBeenCalled(); + }); + }); + test('setUser', () => { const scope = new Scope(); scope.setUser({ id: '1' }); @@ -329,12 +487,18 @@ describe('Scope', () => { const oldPropagationContext = scope.getScopeData().propagationContext; scope.setExtra('a', 2); scope.setTag('a', 'b'); + scope.setAttribute('c', 'd'); scope.setUser({ id: '1' }); scope.setFingerprint(['abcd']); scope.addBreadcrumb({ message: 'test' }); + + expect(scope['_attributes']).toEqual({ c: 'd' }); expect(scope['_extra']).toEqual({ a: 2 }); + scope.clear(); + expect(scope['_extra']).toEqual({}); + expect(scope['_attributes']).toEqual({}); expect(scope['_propagationContext']).toEqual({ traceId: expect.any(String), sampled: undefined, @@ -357,6 +521,7 @@ describe('Scope', () => { beforeEach(() => { scope = new Scope(); scope.setTags({ foo: '1', bar: '2' }); + scope.setAttribute('attr1', 'value1'); scope.setExtras({ foo: '1', bar: '2' }); scope.setContext('foo', { id: '1' }); scope.setContext('bar', { id: '2' }); From e8a1826167e19bccd5d4bb5bfdb5cbe2f9b70d73 Mon Sep 17 00:00:00 2001 From: Abdul Mateen <59867217+JealousGx@users.noreply.github.com> Date: Sat, 6 Sep 2025 10:44:26 +0500 Subject: [PATCH 20/32] feat(node): Fix local variables capturing for out-of-app frames (#18245) Address an issue where local variables were not being captured for out-of-app frames, even when the `includeOutOfAppFrames` option was enabled. The `localVariablesSyncIntegration` had a race condition where it would process events before the debugger session was fully initialized. Fix this by awaiting the session creation in `setupOnce`. The tests for this integration were failing because they were not setting up a Sentry client, which is required for the integration to be enabled. Correct by adding a client to the test setup. Additionally, add tests for the `localVariablesAsyncIntegration` to ensure it correctly handles the `includeOutOfAppFrames` option. The `LocalVariables` integrations `setupOnce` method was `async`, which violates the `Integration` interface. This caused a race condition where events could be processed before the integration was fully initialized, leading to missed local variables. Fix the race condition by: - Make `setupOnce` synchronous to adhere to the interface contract - Move the asynchronous initialization logic to a separate `setup` function - Make `processEvent` asynchronous and await the result of the `setup` function, so the integration is fully initialized before processing any events - Update tests to correctly `await` the `processEvent` method Fixes GH-12588 Fixes GH-17545 --- .../local-variables-out-of-app-default.js | 28 +++ .../local-variables-out-of-app.js | 33 +++ .../suites/public-api/LocalVariables/test.ts | 73 +++++- .../integrations/local-variables/common.ts | 6 + .../local-variables/local-variables-async.ts | 4 +- .../local-variables/local-variables-sync.ts | 218 +++++++++--------- 6 files changed, 253 insertions(+), 109 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app-default.js create mode 100644 dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app.js diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app-default.js b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app-default.js new file mode 100644 index 000000000000..9a53436867d9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app-default.js @@ -0,0 +1,28 @@ +/* eslint-disable no-unused-vars */ + +const Sentry = require('@sentry/node'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); + +const externalFunctionFile = require.resolve('./node_modules/out-of-app-function.js'); + +const { out_of_app_function } = require(externalFunctionFile); + +function in_app_function() { + const inAppVar = 'in app value'; + out_of_app_function(`${inAppVar} modified value`); +} + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, + includeLocalVariables: true, +}); + +setTimeout(async () => { + try { + in_app_function(); + } catch (e) { + Sentry.captureException(e); + await Sentry.flush(); + } +}, 500); diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app.js b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app.js new file mode 100644 index 000000000000..9bbe40004fc7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app.js @@ -0,0 +1,33 @@ +/* eslint-disable no-unused-vars */ + +const Sentry = require('@sentry/node'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); + +const externalFunctionFile = require.resolve('./node_modules/out-of-app-function.js'); + +const { out_of_app_function } = require(externalFunctionFile); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, + includeLocalVariables: true, + integrations: [ + Sentry.localVariablesIntegration({ + includeOutOfAppFrames: true, + }), + ], +}); + +function in_app_function() { + const inAppVar = 'in app value'; + out_of_app_function(`${inAppVar} modified value`); +} + +setTimeout(async () => { + try { + in_app_function(); + } catch (e) { + Sentry.captureException(e); + await Sentry.flush(); + } +}, 500); diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts index 2c87d14c2b45..6c042d3ecf1f 100644 --- a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts @@ -1,5 +1,6 @@ +import { mkdirSync, rmdirSync, unlinkSync, writeFileSync } from 'fs'; import * as path from 'path'; -import { afterAll, describe, expect, test } from 'vitest'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; import { conditionalTest } from '../../../utils'; import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; @@ -39,8 +40,35 @@ const EXPECTED_LOCAL_VARIABLES_EVENT = { }; describe('LocalVariables integration', () => { + const nodeModules = `${__dirname}/node_modules`; + const externalModule = `${nodeModules}//out-of-app-function.js`; + function cleanupExternalModuleFile() { + try { + unlinkSync(externalModule); + // eslint-disable-next-line no-empty + } catch {} + try { + rmdirSync(nodeModules); + // eslint-disable-next-line no-empty + } catch {} + } + + beforeAll(() => { + cleanupExternalModuleFile(); + mkdirSync(nodeModules, { recursive: true }); + writeFileSync( + externalModule, + ` +function out_of_app_function(passedArg) { + const outOfAppVar = "out of app value " + passedArg.substring(13); + throw new Error("out-of-app error"); +} +module.exports = { out_of_app_function };`, + ); + }); afterAll(() => { cleanupChildProcesses(); + cleanupExternalModuleFile(); }); test('Should not include local variables by default', async () => { @@ -127,4 +155,47 @@ describe('LocalVariables integration', () => { .start() .completed(); }); + + test('adds local variables to out of app frames when includeOutOfAppFrames is true', async () => { + await createRunner(__dirname, 'local-variables-out-of-app.js') + .expect({ + event: event => { + const frames = event.exception?.values?.[0]?.stacktrace?.frames || []; + + const inAppFrame = frames.find(frame => frame.function === 'in_app_function'); + const outOfAppFrame = frames.find(frame => frame.function === 'out_of_app_function'); + + expect(inAppFrame?.vars).toEqual({ inAppVar: 'in app value' }); + expect(inAppFrame?.in_app).toEqual(true); + + expect(outOfAppFrame?.vars).toEqual({ + outOfAppVar: 'out of app value modified value', + passedArg: 'in app value modified value', + }); + expect(outOfAppFrame?.in_app).toEqual(false); + }, + }) + .start() + .completed(); + }); + + test('does not add local variables to out of app frames by default', async () => { + await createRunner(__dirname, 'local-variables-out-of-app-default.js') + .expect({ + event: event => { + const frames = event.exception?.values?.[0]?.stacktrace?.frames || []; + + const inAppFrame = frames.find(frame => frame.function === 'in_app_function'); + const outOfAppFrame = frames.find(frame => frame.function === 'out_of_app_function'); + + expect(inAppFrame?.vars).toEqual({ inAppVar: 'in app value' }); + expect(inAppFrame?.in_app).toEqual(true); + + expect(outOfAppFrame?.vars).toBeUndefined(); + expect(outOfAppFrame?.in_app).toEqual(false); + }, + }) + .start() + .completed(); + }); }); diff --git a/packages/node-core/src/integrations/local-variables/common.ts b/packages/node-core/src/integrations/local-variables/common.ts index 471fa1a69864..f86988b4cbfc 100644 --- a/packages/node-core/src/integrations/local-variables/common.ts +++ b/packages/node-core/src/integrations/local-variables/common.ts @@ -99,6 +99,12 @@ export interface LocalVariablesIntegrationOptions { * Maximum number of exceptions to capture local variables for per second before rate limiting is triggered. */ maxExceptionsPerSecond?: number; + /** + * When true, local variables will be captured for all frames, including those that are not in_app. + * + * Defaults to `false`. + */ + includeOutOfAppFrames?: boolean; } export interface LocalVariablesWorkerArgs extends LocalVariablesIntegrationOptions { diff --git a/packages/node-core/src/integrations/local-variables/local-variables-async.ts b/packages/node-core/src/integrations/local-variables/local-variables-async.ts index 32fff66bab4e..7bad543c2588 100644 --- a/packages/node-core/src/integrations/local-variables/local-variables-async.ts +++ b/packages/node-core/src/integrations/local-variables/local-variables-async.ts @@ -39,8 +39,8 @@ export const localVariablesAsyncIntegration = defineIntegration((( if ( // We need to have vars to add frameLocalVariables.vars === undefined || - // We're not interested in frames that are not in_app because the vars are not relevant - frame.in_app === false || + // Only skip out-of-app frames if includeOutOfAppFrames is not true + (frame.in_app === false && integrationOptions.includeOutOfAppFrames !== true) || // The function names need to match !functionNamesMatch(frame.function, frameLocalVariables.function) ) { diff --git a/packages/node-core/src/integrations/local-variables/local-variables-sync.ts b/packages/node-core/src/integrations/local-variables/local-variables-sync.ts index 7de91a54276e..b2af37b0c7fb 100644 --- a/packages/node-core/src/integrations/local-variables/local-variables-sync.ts +++ b/packages/node-core/src/integrations/local-variables/local-variables-sync.ts @@ -268,8 +268,8 @@ const _localVariablesSyncIntegration = (( if ( // We need to have vars to add cachedFrameVariable.vars === undefined || - // We're not interested in frames that are not in_app because the vars are not relevant - frameVariable.in_app === false || + // Only skip out-of-app frames if includeOutOfAppFrames is not true + (frameVariable.in_app === false && options.includeOutOfAppFrames !== true) || // The function names need to match !functionNamesMatch(frameVariable.function, cachedFrameVariable.function) ) { @@ -288,122 +288,128 @@ const _localVariablesSyncIntegration = (( return event; } - return { - name: INTEGRATION_NAME, - async setupOnce() { - const client = getClient (); - const clientOptions = client?.getOptions(); + let setupPromise: Promise | undefined; - if (!clientOptions?.includeLocalVariables) { - return; - } + async function setup(): Promise { + const client = getClient (); + const clientOptions = client?.getOptions(); - // Only setup this integration if the Node version is >= v18 - // https://github.com/getsentry/sentry-javascript/issues/7697 - const unsupportedNodeVersion = NODE_MAJOR < 18; + if (!clientOptions?.includeLocalVariables) { + return; + } - if (unsupportedNodeVersion) { - debug.log('The `LocalVariables` integration is only supported on Node >= v18.'); - return; - } + // Only setup this integration if the Node version is >= v18 + // https://github.com/getsentry/sentry-javascript/issues/7697 + const unsupportedNodeVersion = NODE_MAJOR < 18; - if (await isDebuggerEnabled()) { - debug.warn('Local variables capture has been disabled because the debugger was already enabled'); - return; - } + if (unsupportedNodeVersion) { + debug.log('The `LocalVariables` integration is only supported on Node >= v18.'); + return; + } - AsyncSession.create(sessionOverride).then( - session => { - function handlePaused( - stackParser: StackParser, - { params: { reason, data, callFrames } }: InspectorNotification , - complete: () => void, - ): void { - if (reason !== 'exception' && reason !== 'promiseRejection') { - complete(); - return; - } - - rateLimiter?.(); - - // data.description contains the original error.stack - const exceptionHash = hashFromStack(stackParser, data.description); - - if (exceptionHash == undefined) { - complete(); - return; - } - - const { add, next } = createCallbackList (frames => { - cachedFrames.set(exceptionHash, frames); - complete(); - }); + if (await isDebuggerEnabled()) { + debug.warn('Local variables capture has been disabled because the debugger was already enabled'); + return; + } - // Because we're queuing up and making all these calls synchronously, we can potentially overflow the stack - // For this reason we only attempt to get local variables for the first 5 frames - for (let i = 0; i < Math.min(callFrames.length, 5); i++) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const { scopeChain, functionName, this: obj } = callFrames[i]!; - - const localScope = scopeChain.find(scope => scope.type === 'local'); - - // obj.className is undefined in ESM modules - const fn = - obj.className === 'global' || !obj.className ? functionName : `${obj.className}.${functionName}`; - - if (localScope?.object.objectId === undefined) { - add(frames => { - frames[i] = { function: fn }; - next(frames); - }); - } else { - const id = localScope.object.objectId; - add(frames => - session.getLocalVariables(id, vars => { - frames[i] = { function: fn, vars }; - next(frames); - }), - ); - } - } - - next([]); - } + try { + const session = await AsyncSession.create(sessionOverride); + + const handlePaused = ( + stackParser: StackParser, + { params: { reason, data, callFrames } }: InspectorNotification , + complete: () => void, + ): void => { + if (reason !== 'exception' && reason !== 'promiseRejection') { + complete(); + return; + } + + rateLimiter?.(); + + // data.description contains the original error.stack + const exceptionHash = hashFromStack(stackParser, data.description); + + if (exceptionHash == undefined) { + complete(); + return; + } - const captureAll = options.captureAllExceptions !== false; - - session.configureAndConnect( - (ev, complete) => - handlePaused(clientOptions.stackParser, ev as InspectorNotification , complete), - captureAll, - ); - - if (captureAll) { - const max = options.maxExceptionsPerSecond || 50; - - rateLimiter = createRateLimiter( - max, - () => { - debug.log('Local variables rate-limit lifted.'); - session.setPauseOnExceptions(true); - }, - seconds => { - debug.log( - `Local variables rate-limit exceeded. Disabling capturing of caught exceptions for ${seconds} seconds.`, - ); - session.setPauseOnExceptions(false); - }, + const { add, next } = createCallbackList (frames => { + cachedFrames.set(exceptionHash, frames); + complete(); + }); + + // Because we're queuing up and making all these calls synchronously, we can potentially overflow the stack + // For this reason we only attempt to get local variables for the first 5 frames + for (let i = 0; i < Math.min(callFrames.length, 5); i++) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const { scopeChain, functionName, this: obj } = callFrames[i]!; + + const localScope = scopeChain.find(scope => scope.type === 'local'); + + // obj.className is undefined in ESM modules + const fn = obj.className === 'global' || !obj.className ? functionName : `${obj.className}.${functionName}`; + + if (localScope?.object.objectId === undefined) { + add(frames => { + frames[i] = { function: fn }; + next(frames); + }); + } else { + const id = localScope.object.objectId; + add(frames => + session.getLocalVariables(id, vars => { + frames[i] = { function: fn, vars }; + next(frames); + }), ); } + } + + next([]); + }; - shouldProcessEvent = true; - }, - error => { - debug.log('The `LocalVariables` integration failed to start.', error); - }, + const captureAll = options.captureAllExceptions !== false; + + session.configureAndConnect( + (ev, complete) => + handlePaused(clientOptions.stackParser, ev as InspectorNotification , complete), + captureAll, ); + + if (captureAll) { + const max = options.maxExceptionsPerSecond || 50; + + rateLimiter = createRateLimiter( + max, + () => { + debug.log('Local variables rate-limit lifted.'); + session.setPauseOnExceptions(true); + }, + seconds => { + debug.log( + `Local variables rate-limit exceeded. Disabling capturing of caught exceptions for ${seconds} seconds.`, + ); + session.setPauseOnExceptions(false); + }, + ); + } + + shouldProcessEvent = true; + } catch (error) { + debug.log('The `LocalVariables` integration failed to start.', error); + } + } + + return { + name: INTEGRATION_NAME, + setupOnce() { + setupPromise = setup(); }, - processEvent(event: Event): Event { + async processEvent(event: Event): Promise { + await setupPromise; + if (shouldProcessEvent) { return addLocalVariablesToEvent(event); } From cbecbdf97b0c402afc324226e63427dfbfc21727 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Sat, 22 Nov 2025 00:11:18 +0100 Subject: [PATCH 21/32] feat(deps): Bump OpenTelemetry instrumentations (#18239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR bumps OpenTelemetry instrumentations and SDK packages to their latest versions. ## Dependency Updates: * @opentelemetry/context-async-hooks: 2.1.0 → 2.2.0 * @opentelemetry/core: 2.1.0 → 2.2.0 * @opentelemetry/resources: 2.1.0 → 2.2.0 * @opentelemetry/sdk-trace-base: 2.1.0 → 2.2.0 * @opentelemetry/sdk-trace-node: 2.1.0 → 2.2.0 * @opentelemetry/instrumentation: 0.204.0 → 0.208.0 * @opentelemetry/instrumentation-mongodb: 0.57.0 → 0.61.0 * @opentelemetry/instrumentation-pg: 0.57.0 → 0.61.0 * @opentelemetry/instrumentation-mysql: 0.50.0 → 0.54.0 * @opentelemetry/instrumentation-mysql2: 0.51.0 → 0.55.0 * @opentelemetry/instrumentation-tedious: 0.23.0 → 0.27.0 * @opentelemetry/instrumentation-mongoose: 0.51.0 → 0.55.0 * @opentelemetry/instrumentation-redis: 0.53.0 → 0.57.0 * @opentelemetry/instrumentation-ioredis: 0.52.0 → 0.56.0 * @opentelemetry/instrumentation-express: 0.53.0 → 0.57.0 * @opentelemetry/instrumentation-koa: 0.52.0 → 0.57.0 * @opentelemetry/instrumentation-hapi: 0.51.0 → 0.55.0 * @opentelemetry/instrumentation-connect: 0.48.0 → 0.52.0 * @opentelemetry/instrumentation-nestjs-core: 0.50.0 → 0.55.0 * @opentelemetry/instrumentation-http: 0.204.0 → 0.208.0 * @opentelemetry/instrumentation-graphql: 0.52.0 → 0.56.0 * @opentelemetry/instrumentation-amqplib: 0.51.0 → 0.55.0 * @opentelemetry/instrumentation-aws-sdk: 0.59.0 → 0.64.0 * @opentelemetry/instrumentation-dataloader: 0.22.0 → 0.26.0 * @opentelemetry/instrumentation-fs: 0.24.0 → 0.28.0 * @opentelemetry/instrumentation-generic-pool: 0.48.0 → 0.52.0 * @opentelemetry/instrumentation-kafkajs: 0.14.0 → 0.18.0 * @opentelemetry/instrumentation-knex: 0.49.0 → 0.53.0 * @opentelemetry/instrumentation-lru-memoizer: 0.49.0 → 0.53.0 * @opentelemetry/instrumentation-undici: 0.15.0 → 0.19.0 * @prisma/instrumentation: 6.15.0 → 6.19.0 Closes: #18178 --- CHANGELOG.md | 33 ++ .../test-applications/nextjs-16/package.json | 4 +- .../package.json | 12 +- .../package.json | 16 +- .../node-core-express-otel-v2/package.json | 12 +- .../node-otel-sdk-node/package.json | 4 +- .../node-otel-without-tracing/package.json | 8 +- .../tests/transactions.test.ts | 4 + .../test-applications/node-otel/package.json | 4 +- .../node-core-integration-tests/package.json | 12 +- packages/aws-serverless/package.json | 4 +- packages/nestjs/package.json | 6 +- packages/node-core/package.json | 20 +- .../src/integrations/diagnostic_channel.d.ts | 556 ------------------ packages/node/package.json | 58 +- packages/opentelemetry/package.json | 12 +- packages/react-router/package.json | 4 +- packages/remix/package.json | 2 +- packages/sveltekit/src/client/sdk.ts | 4 +- packages/vercel-edge/package.json | 6 +- .../abstract-async-hooks-context-manager.ts | 14 +- .../async-local-storage-context-manager.ts | 12 +- yarn.lock | 423 ++++++------- 23 files changed, 347 insertions(+), 883 deletions(-) delete mode 100644 packages/node-core/src/integrations/diagnostic_channel.d.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5eba860932aa..479b72fc2f08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,39 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +- feat(deps): Bump OpenTelemetry ([#18239](https://github.com/getsentry/sentry-javascript/pull/18239)) + - Bump @opentelemetry/context-async-hooks from ^2.1.0 to ^2.2.0 + - Bump @opentelemetry/core from ^2.1.0 to ^2.2.0 + - Bump @opentelemetry/resources from ^2.1.0 to ^2.2.0 + - Bump @opentelemetry/sdk-trace-base from ^2.1.0 to ^2.2.0 + - Bump @opentelemetry/sdk-trace-node from ^2.1.0 to ^2.2.0 + - Bump @opentelemetry/instrumentation from 0.204.0 to 0.208.0 + - Bump @opentelemetry/instrumentation-amqplib from 0.51.0 to 0.55.0 + - Bump @opentelemetry/instrumentation-aws-sdk from 0.59.0 to 0.64.0 + - Bump @opentelemetry/instrumentation-connect from 0.48.0 to 0.52.0 + - Bump @opentelemetry/instrumentation-dataloader from 0.22.0 to 0.26.0 + - Bump @opentelemetry/instrumentation-express from 0.53.0 to 0.57.0 + - Bump @opentelemetry/instrumentation-fs from 0.24.0 to 0.28.0 + - Bump @opentelemetry/instrumentation-generic-pool from 0.48.0 to 0.52.0 + - Bump @opentelemetry/instrumentation-graphql from 0.52.0 to 0.56.0 + - Bump @opentelemetry/instrumentation-hapi from 0.51.0 to 0.55.0 + - Bump @opentelemetry/instrumentation-http from 0.204.0 to 0.208.0 + - Bump @opentelemetry/instrumentation-ioredis from 0.52.0 to 0.56.0 + - Bump @opentelemetry/instrumentation-kafkajs from 0.14.0 to 0.18.0 + - Bump @opentelemetry/instrumentation-knex from 0.49.0 to 0.53.0 + - Bump @opentelemetry/instrumentation-koa from 0.52.0 to 0.57.0 + - Bump @opentelemetry/instrumentation-lru-memoizer from 0.49.0 to 0.53.0 + - Bump @opentelemetry/instrumentation-mongodb from 0.57.0 to 0.61.0 + - Bump @opentelemetry/instrumentation-mongoose from 0.51.0 to 0.55.0 + - Bump @opentelemetry/instrumentation-mysql from 0.50.0 to 0.54.0 + - Bump @opentelemetry/instrumentation-mysql2 from 0.51.0 to 0.55.0 + - Bump @opentelemetry/instrumentation-nestjs-core from 0.50.0 to 0.55.0 + - Bump @opentelemetry/instrumentation-pg from 0.57.0 to 0.61.0 + - Bump @opentelemetry/instrumentation-redis from 0.53.0 to 0.57.0 + - Bump @opentelemetry/instrumentation-tedious from 0.23.0 to 0.27.0 + - Bump @opentelemetry/instrumentation-undici from 0.15.0 to 0.19.0 + - Bump @prisma/instrumentation from 6.15.0 to 6.19.0 + ## 10.26.0 ### Important Changes diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index af9f306f017d..662e1b85936a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -26,11 +26,11 @@ "@sentry/nextjs": "latest || *", "@sentry/core": "latest || *", "ai": "^3.0.0", - "import-in-the-middle": "^1", + "import-in-the-middle": "^2", "next": "16.0.0", "react": "19.1.0", "react-dom": "19.1.0", - "require-in-the-middle": "^7", + "require-in-the-middle": "^8", "zod": "^3.22.4" }, "devDependencies": { diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json index e29a40c2887e..5710105d4ab8 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json @@ -12,12 +12,12 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.1.0", - "@opentelemetry/core": "^2.1.0", - "@opentelemetry/instrumentation": "^0.204.0", - "@opentelemetry/instrumentation-http": "^0.204.0", - "@opentelemetry/resources": "^2.1.0", - "@opentelemetry/sdk-trace-node": "^2.1.0", + "@opentelemetry/context-async-hooks": "^2.2.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/instrumentation-http": "^0.208.0", + "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/sdk-trace-node": "^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@sentry/node-core": "latest || *", "@sentry/opentelemetry": "latest || *", diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json index 34b050f350c1..f6074d159bbe 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json @@ -12,15 +12,15 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.1.0", - "@opentelemetry/core": "^2.1.0", - "@opentelemetry/instrumentation": "^0.204.0", - "@opentelemetry/instrumentation-http": "^0.204.0", - "@opentelemetry/resources": "^2.1.0", - "@opentelemetry/sdk-trace-node": "^2.1.0", + "@opentelemetry/context-async-hooks": "^2.2.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/instrumentation-http": "^0.208.0", + "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/sdk-trace-node": "^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@opentelemetry/sdk-node": "^0.204.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.204.0", + "@opentelemetry/sdk-node": "^0.208.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.208.0", "@sentry/node-core": "latest || *", "@sentry/opentelemetry": "latest || *", "@types/express": "4.17.17", diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json index 2252750e423e..b9ba557d67b5 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json @@ -14,12 +14,12 @@ "@sentry/node-core": "latest || *", "@sentry/opentelemetry": "latest || *", "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.1.0", - "@opentelemetry/core": "^2.1.0", - "@opentelemetry/instrumentation": "^0.204.0", - "@opentelemetry/instrumentation-http": "^0.204.0", - "@opentelemetry/resources": "^2.1.0", - "@opentelemetry/sdk-trace-node": "^2.1.0", + "@opentelemetry/context-async-hooks": "^2.2.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/instrumentation-http": "^0.208.0", + "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/sdk-trace-node": "^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@types/express": "^4.17.21", "@types/node": "^18.19.1", diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json index 7296f72218cd..1eb93f281cf8 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json @@ -11,8 +11,8 @@ "test:assert": "pnpm test" }, "dependencies": { - "@opentelemetry/sdk-node": "0.204.0", - "@opentelemetry/exporter-trace-otlp-http": "0.204.0", + "@opentelemetry/sdk-node": "0.208.0", + "@opentelemetry/exporter-trace-otlp-http": "0.208.0", "@sentry/node": "latest || *", "@types/express": "4.17.17", "@types/node": "^18.19.1", diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json index f13daab2ef6c..fc153ddceeb8 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json @@ -11,11 +11,11 @@ "test:assert": "pnpm test" }, "dependencies": { - "@opentelemetry/sdk-trace-node": "2.1.0", - "@opentelemetry/exporter-trace-otlp-http": "0.204.0", + "@opentelemetry/sdk-trace-node": "2.2.0", + "@opentelemetry/exporter-trace-otlp-http": "0.208.0", "@opentelemetry/instrumentation-undici": "0.13.2", - "@opentelemetry/instrumentation-http": "0.204.0", - "@opentelemetry/instrumentation": "0.204.0", + "@opentelemetry/instrumentation-http": "0.208.0", + "@opentelemetry/instrumentation": "0.208.0", "@sentry/node": "latest || *", "@types/express": "4.17.17", "@types/node": "^18.19.1", diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/transactions.test.ts index 678841bdb249..26c9d7de5496 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/transactions.test.ts @@ -65,6 +65,7 @@ test('Sends an API route transaction to OTLP', async ({ baseURL }) => { status: { code: 0 }, links: [], droppedLinksCount: 0, + flags: expect.any(Number), }, { traceId: expect.any(String), @@ -80,6 +81,7 @@ test('Sends an API route transaction to OTLP', async ({ baseURL }) => { status: { code: 0 }, links: [], droppedLinksCount: 0, + flags: expect.any(Number), }, ]); @@ -116,6 +118,7 @@ test('Sends an API route transaction to OTLP', async ({ baseURL }) => { status: { code: 0 }, links: [], droppedLinksCount: 0, + flags: expect.any(Number), }, ]); @@ -157,6 +160,7 @@ test('Sends an API route transaction to OTLP', async ({ baseURL }) => { }, links: [], droppedLinksCount: 0, + flags: expect.any(Number), }, ]); }); diff --git a/dev-packages/e2e-tests/test-applications/node-otel/package.json b/dev-packages/e2e-tests/test-applications/node-otel/package.json index 31cf99c32c91..e2b7086f23ba 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel/package.json +++ b/dev-packages/e2e-tests/test-applications/node-otel/package.json @@ -11,8 +11,8 @@ "test:assert": "pnpm test" }, "dependencies": { - "@opentelemetry/sdk-node": "0.204.0", - "@opentelemetry/exporter-trace-otlp-http": "0.204.0", + "@opentelemetry/sdk-node": "0.208.0", + "@opentelemetry/exporter-trace-otlp-http": "0.208.0", "@sentry/node": "latest || *", "@types/express": "4.17.17", "@types/node": "^18.19.1", diff --git a/dev-packages/node-core-integration-tests/package.json b/dev-packages/node-core-integration-tests/package.json index fe755f16cc6d..24ac2f57ea9e 100644 --- a/dev-packages/node-core-integration-tests/package.json +++ b/dev-packages/node-core-integration-tests/package.json @@ -27,12 +27,12 @@ "@nestjs/core": "^11", "@nestjs/platform-express": "^11", "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.1.0", - "@opentelemetry/core": "^2.1.0", - "@opentelemetry/instrumentation": "^0.204.0", - "@opentelemetry/instrumentation-http": "0.204.0", - "@opentelemetry/resources": "^2.1.0", - "@opentelemetry/sdk-trace-base": "^2.1.0", + "@opentelemetry/context-async-hooks": "^2.2.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/instrumentation-http": "0.208.0", + "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/sdk-trace-base": "^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@sentry/core": "10.26.0", "@sentry/node-core": "10.26.0", diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index cd0ad16d9e7c..8d6360e82d2a 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -66,8 +66,8 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/instrumentation": "^0.204.0", - "@opentelemetry/instrumentation-aws-sdk": "0.59.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/instrumentation-aws-sdk": "0.64.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@sentry/core": "10.26.0", "@sentry/node": "10.26.0", diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index 53c3064ed08f..ae39e2dc5d4f 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -45,9 +45,9 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/core": "^2.1.0", - "@opentelemetry/instrumentation": "^0.204.0", - "@opentelemetry/instrumentation-nestjs-core": "0.50.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/instrumentation-nestjs-core": "0.55.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@sentry/core": "10.26.0", "@sentry/node": "10.26.0" diff --git a/packages/node-core/package.json b/packages/node-core/package.json index 89dbe5461165..1f845acfa16b 100644 --- a/packages/node-core/package.json +++ b/packages/node-core/package.json @@ -58,27 +58,27 @@ }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", - "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0 || ^2.2.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0 || ^2.2.0", "@opentelemetry/instrumentation": ">=0.57.1 <1", - "@opentelemetry/resources": "^1.30.1 || ^2.1.0", - "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", + "@opentelemetry/resources": "^1.30.1 || ^2.1.0 || ^2.2.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0 || ^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0" }, "dependencies": { "@apm-js-collab/tracing-hooks": "^0.3.1", "@sentry/core": "10.26.0", "@sentry/opentelemetry": "10.26.0", - "import-in-the-middle": "^1.14.2" + "import-in-the-middle": "^2" }, "devDependencies": { "@apm-js-collab/code-transformer": "^0.8.2", "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.1.0", - "@opentelemetry/core": "^2.1.0", - "@opentelemetry/instrumentation": "^0.204.0", - "@opentelemetry/resources": "^2.1.0", - "@opentelemetry/sdk-trace-base": "^2.1.0", + "@opentelemetry/context-async-hooks": "^2.2.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/sdk-trace-base": "^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@types/node": "^18.19.1" }, diff --git a/packages/node-core/src/integrations/diagnostic_channel.d.ts b/packages/node-core/src/integrations/diagnostic_channel.d.ts deleted file mode 100644 index abf3649a617f..000000000000 --- a/packages/node-core/src/integrations/diagnostic_channel.d.ts +++ /dev/null @@ -1,556 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/ban-types */ -/* eslint-disable @typescript-eslint/explicit-member-accessibility */ - -/** - * The `node:diagnostics_channel` module provides an API to create named channels - * to report arbitrary message data for diagnostics purposes. - * - * It can be accessed using: - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * ``` - * - * It is intended that a module writer wanting to report diagnostics messages - * will create one or many top-level channels to report messages through. - * Channels may also be acquired at runtime but it is not encouraged - * due to the additional overhead of doing so. Channels may be exported for - * convenience, but as long as the name is known it can be acquired anywhere. - * - * If you intend for your module to produce diagnostics data for others to - * consume it is recommended that you include documentation of what named - * channels are used along with the shape of the message data. Channel names - * should generally include the module name to avoid collisions with data from - * other modules. - * @since v15.1.0, v14.17.0 - * @see [source](https://github.com/nodejs/node/blob/v22.x/lib/diagnostics_channel.js) - */ -declare module 'diagnostics_channel' { - import type { AsyncLocalStorage } from 'node:async_hooks'; - /** - * Check if there are active subscribers to the named channel. This is helpful if - * the message you want to send might be expensive to prepare. - * - * This API is optional but helpful when trying to publish messages from very - * performance-sensitive code. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * if (diagnostics_channel.hasSubscribers('my-channel')) { - * // There are subscribers, prepare and publish message - * } - * ``` - * @since v15.1.0, v14.17.0 - * @param name The channel name - * @return If there are active subscribers - */ - function hasSubscribers(name: string | symbol): boolean; - /** - * This is the primary entry-point for anyone wanting to publish to a named - * channel. It produces a channel object which is optimized to reduce overhead at - * publish time as much as possible. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * const channel = diagnostics_channel.channel('my-channel'); - * ``` - * @since v15.1.0, v14.17.0 - * @param name The channel name - * @return The named channel object - */ - function channel(name: string | symbol): Channel; - type ChannelListener = (message: unknown, name: string | symbol) => void; - /** - * Register a message handler to subscribe to this channel. This message handler - * will be run synchronously whenever a message is published to the channel. Any - * errors thrown in the message handler will trigger an `'uncaughtException'`. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * diagnostics_channel.subscribe('my-channel', (message, name) => { - * // Received data - * }); - * ``` - * @since v18.7.0, v16.17.0 - * @param name The channel name - * @param onMessage The handler to receive channel messages - */ - function subscribe(name: string | symbol, onMessage: ChannelListener): void; - /** - * Remove a message handler previously registered to this channel with {@link subscribe}. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * function onMessage(message, name) { - * // Received data - * } - * - * diagnostics_channel.subscribe('my-channel', onMessage); - * - * diagnostics_channel.unsubscribe('my-channel', onMessage); - * ``` - * @since v18.7.0, v16.17.0 - * @param name The channel name - * @param onMessage The previous subscribed handler to remove - * @return `true` if the handler was found, `false` otherwise. - */ - function unsubscribe(name: string | symbol, onMessage: ChannelListener): boolean; - /** - * Creates a `TracingChannel` wrapper for the given `TracingChannel Channels`. If a name is given, the corresponding tracing - * channels will be created in the form of `tracing:${name}:${eventType}` where `eventType` corresponds to the types of `TracingChannel Channels`. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * const channelsByName = diagnostics_channel.tracingChannel('my-channel'); - * - * // or... - * - * const channelsByCollection = diagnostics_channel.tracingChannel({ - * start: diagnostics_channel.channel('tracing:my-channel:start'), - * end: diagnostics_channel.channel('tracing:my-channel:end'), - * asyncStart: diagnostics_channel.channel('tracing:my-channel:asyncStart'), - * asyncEnd: diagnostics_channel.channel('tracing:my-channel:asyncEnd'), - * error: diagnostics_channel.channel('tracing:my-channel:error'), - * }); - * ``` - * @since v19.9.0 - * @experimental - * @param nameOrChannels Channel name or object containing all the `TracingChannel Channels` - * @return Collection of channels to trace with - */ - function tracingChannel< - StoreType = unknown, - ContextType extends object = StoreType extends object ? StoreType : object, - >(nameOrChannels: string | TracingChannelCollection ): TracingChannel ; - /** - * The class `Channel` represents an individual named channel within the data - * pipeline. It is used to track subscribers and to publish messages when there - * are subscribers present. It exists as a separate object to avoid channel - * lookups at publish time, enabling very fast publish speeds and allowing - * for heavy use while incurring very minimal cost. Channels are created with {@link channel}, constructing a channel directly - * with `new Channel(name)` is not supported. - * @since v15.1.0, v14.17.0 - */ - class Channel { - readonly name: string | symbol; - /** - * Check if there are active subscribers to this channel. This is helpful if - * the message you want to send might be expensive to prepare. - * - * This API is optional but helpful when trying to publish messages from very - * performance-sensitive code. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * const channel = diagnostics_channel.channel('my-channel'); - * - * if (channel.hasSubscribers) { - * // There are subscribers, prepare and publish message - * } - * ``` - * @since v15.1.0, v14.17.0 - */ - readonly hasSubscribers: boolean; - private constructor(name: string | symbol); - /** - * Publish a message to any subscribers to the channel. This will trigger - * message handlers synchronously so they will execute within the same context. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * const channel = diagnostics_channel.channel('my-channel'); - * - * channel.publish({ - * some: 'message', - * }); - * ``` - * @since v15.1.0, v14.17.0 - * @param message The message to send to the channel subscribers - */ - publish(message: unknown): void; - /** - * Register a message handler to subscribe to this channel. This message handler - * will be run synchronously whenever a message is published to the channel. Any - * errors thrown in the message handler will trigger an `'uncaughtException'`. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * const channel = diagnostics_channel.channel('my-channel'); - * - * channel.subscribe((message, name) => { - * // Received data - * }); - * ``` - * @since v15.1.0, v14.17.0 - * @deprecated Since v18.7.0,v16.17.0 - Use {@link subscribe(name, onMessage)} - * @param onMessage The handler to receive channel messages - */ - subscribe(onMessage: ChannelListener): void; - /** - * Remove a message handler previously registered to this channel with `channel.subscribe(onMessage)`. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * const channel = diagnostics_channel.channel('my-channel'); - * - * function onMessage(message, name) { - * // Received data - * } - * - * channel.subscribe(onMessage); - * - * channel.unsubscribe(onMessage); - * ``` - * @since v15.1.0, v14.17.0 - * @deprecated Since v18.7.0,v16.17.0 - Use {@link unsubscribe(name, onMessage)} - * @param onMessage The previous subscribed handler to remove - * @return `true` if the handler was found, `false` otherwise. - */ - unsubscribe(onMessage: ChannelListener): void; - /** - * When `channel.runStores(context, ...)` is called, the given context data - * will be applied to any store bound to the channel. If the store has already been - * bound the previous `transform` function will be replaced with the new one. - * The `transform` function may be omitted to set the given context data as the - * context directly. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * import { AsyncLocalStorage } from 'node:async_hooks'; - * - * const store = new AsyncLocalStorage(); - * - * const channel = diagnostics_channel.channel('my-channel'); - * - * channel.bindStore(store, (data) => { - * return { data }; - * }); - * ``` - * @since v19.9.0 - * @experimental - * @param store The store to which to bind the context data - * @param transform Transform context data before setting the store context - */ - bindStore(store: AsyncLocalStorage , transform?: (context: ContextType) => StoreType): void; - /** - * Remove a message handler previously registered to this channel with `channel.bindStore(store)`. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * import { AsyncLocalStorage } from 'node:async_hooks'; - * - * const store = new AsyncLocalStorage(); - * - * const channel = diagnostics_channel.channel('my-channel'); - * - * channel.bindStore(store); - * channel.unbindStore(store); - * ``` - * @since v19.9.0 - * @experimental - * @param store The store to unbind from the channel. - * @return `true` if the store was found, `false` otherwise. - */ - unbindStore(store: any): void; - /** - * Applies the given data to any AsyncLocalStorage instances bound to the channel - * for the duration of the given function, then publishes to the channel within - * the scope of that data is applied to the stores. - * - * If a transform function was given to `channel.bindStore(store)` it will be - * applied to transform the message data before it becomes the context value for - * the store. The prior storage context is accessible from within the transform - * function in cases where context linking is required. - * - * The context applied to the store should be accessible in any async code which - * continues from execution which began during the given function, however - * there are some situations in which `context loss` may occur. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * import { AsyncLocalStorage } from 'node:async_hooks'; - * - * const store = new AsyncLocalStorage(); - * - * const channel = diagnostics_channel.channel('my-channel'); - * - * channel.bindStore(store, (message) => { - * const parent = store.getStore(); - * return new Span(message, parent); - * }); - * channel.runStores({ some: 'message' }, () => { - * store.getStore(); // Span({ some: 'message' }) - * }); - * ``` - * @since v19.9.0 - * @experimental - * @param context Message to send to subscribers and bind to stores - * @param fn Handler to run within the entered storage context - * @param thisArg The receiver to be used for the function call. - * @param args Optional arguments to pass to the function. - */ - runStores(): void; - } - interface TracingChannelSubscribers { - start: (message: ContextType) => void; - end: ( - message: ContextType & { - error?: unknown; - result?: unknown; - }, - ) => void; - asyncStart: ( - message: ContextType & { - error?: unknown; - result?: unknown; - }, - ) => void; - asyncEnd: ( - message: ContextType & { - error?: unknown; - result?: unknown; - }, - ) => void; - error: ( - message: ContextType & { - error: unknown; - }, - ) => void; - } - interface TracingChannelCollection { - start: Channel ; - end: Channel ; - asyncStart: Channel ; - asyncEnd: Channel ; - error: Channel ; - } - /** - * The class `TracingChannel` is a collection of `TracingChannel Channels` which - * together express a single traceable action. It is used to formalize and - * simplify the process of producing events for tracing application flow. {@link tracingChannel} is used to construct a `TracingChannel`. As with `Channel` it is recommended to create and reuse a - * single `TracingChannel` at the top-level of the file rather than creating them - * dynamically. - * @since v19.9.0 - * @experimental - */ - class TracingChannel implements TracingChannelCollection { - start: Channel ; - end: Channel ; - asyncStart: Channel ; - asyncEnd: Channel ; - error: Channel ; - /** - * Helper to subscribe a collection of functions to the corresponding channels. - * This is the same as calling `channel.subscribe(onMessage)` on each channel - * individually. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * const channels = diagnostics_channel.tracingChannel('my-channel'); - * - * channels.subscribe({ - * start(message) { - * // Handle start message - * }, - * end(message) { - * // Handle end message - * }, - * asyncStart(message) { - * // Handle asyncStart message - * }, - * asyncEnd(message) { - * // Handle asyncEnd message - * }, - * error(message) { - * // Handle error message - * }, - * }); - * ``` - * @since v19.9.0 - * @experimental - * @param subscribers Set of `TracingChannel Channels` subscribers - */ - subscribe(subscribers: TracingChannelSubscribers ): void; - /** - * Helper to unsubscribe a collection of functions from the corresponding channels. - * This is the same as calling `channel.unsubscribe(onMessage)` on each channel - * individually. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * const channels = diagnostics_channel.tracingChannel('my-channel'); - * - * channels.unsubscribe({ - * start(message) { - * // Handle start message - * }, - * end(message) { - * // Handle end message - * }, - * asyncStart(message) { - * // Handle asyncStart message - * }, - * asyncEnd(message) { - * // Handle asyncEnd message - * }, - * error(message) { - * // Handle error message - * }, - * }); - * ``` - * @since v19.9.0 - * @experimental - * @param subscribers Set of `TracingChannel Channels` subscribers - * @return `true` if all handlers were successfully unsubscribed, and `false` otherwise. - */ - unsubscribe(subscribers: TracingChannelSubscribers ): void; - /** - * Trace a synchronous function call. This will always produce a `start event` and `end event` around the execution and may produce an `error event` if the given function throws an error. - * This will run the given function using `channel.runStores(context, ...)` on the `start` channel which ensures all - * events should have any bound stores set to match this trace context. - * - * To ensure only correct trace graphs are formed, events will only be published if subscribers are present prior to starting the trace. Subscriptions - * which are added after the trace begins will not receive future events from that trace, only future traces will be seen. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * const channels = diagnostics_channel.tracingChannel('my-channel'); - * - * channels.traceSync(() => { - * // Do something - * }, { - * some: 'thing', - * }); - * ``` - * @since v19.9.0 - * @experimental - * @param fn Function to wrap a trace around - * @param context Shared object to correlate events through - * @param thisArg The receiver to be used for the function call - * @param args Optional arguments to pass to the function - * @return The return value of the given function - */ - traceSync ( - fn: (this: ThisArg, ...args: Args) => any, - context?: ContextType, - thisArg?: ThisArg, - ...args: Args - ): void; - /** - * Trace a promise-returning function call. This will always produce a `start event` and `end event` around the synchronous portion of the - * function execution, and will produce an `asyncStart event` and `asyncEnd event` when a promise continuation is reached. It may also - * produce an `error event` if the given function throws an error or the - * returned promise rejects. This will run the given function using `channel.runStores(context, ...)` on the `start` channel which ensures all - * events should have any bound stores set to match this trace context. - * - * To ensure only correct trace graphs are formed, events will only be published if subscribers are present prior to starting the trace. Subscriptions - * which are added after the trace begins will not receive future events from that trace, only future traces will be seen. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * const channels = diagnostics_channel.tracingChannel('my-channel'); - * - * channels.tracePromise(async () => { - * // Do something - * }, { - * some: 'thing', - * }); - * ``` - * @since v19.9.0 - * @experimental - * @param fn Promise-returning function to wrap a trace around - * @param context Shared object to correlate trace events through - * @param thisArg The receiver to be used for the function call - * @param args Optional arguments to pass to the function - * @return Chained from promise returned by the given function - */ - tracePromise ( - fn: (this: ThisArg, ...args: Args) => Promise , - context?: ContextType, - thisArg?: ThisArg, - ...args: Args - ): void; - /** - * Trace a callback-receiving function call. This will always produce a `start event` and `end event` around the synchronous portion of the - * function execution, and will produce a `asyncStart event` and `asyncEnd event` around the callback execution. It may also produce an `error event` if the given function throws an error or - * the returned - * promise rejects. This will run the given function using `channel.runStores(context, ...)` on the `start` channel which ensures all - * events should have any bound stores set to match this trace context. - * - * The `position` will be -1 by default to indicate the final argument should - * be used as the callback. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * const channels = diagnostics_channel.tracingChannel('my-channel'); - * - * channels.traceCallback((arg1, callback) => { - * // Do something - * callback(null, 'result'); - * }, 1, { - * some: 'thing', - * }, thisArg, arg1, callback); - * ``` - * - * The callback will also be run with `channel.runStores(context, ...)` which - * enables context loss recovery in some cases. - * - * To ensure only correct trace graphs are formed, events will only be published if subscribers are present prior to starting the trace. Subscriptions - * which are added after the trace begins will not receive future events from that trace, only future traces will be seen. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * import { AsyncLocalStorage } from 'node:async_hooks'; - * - * const channels = diagnostics_channel.tracingChannel('my-channel'); - * const myStore = new AsyncLocalStorage(); - * - * // The start channel sets the initial store data to something - * // and stores that store data value on the trace context object - * channels.start.bindStore(myStore, (data) => { - * const span = new Span(data); - * data.span = span; - * return span; - * }); - * - * // Then asyncStart can restore from that data it stored previously - * channels.asyncStart.bindStore(myStore, (data) => { - * return data.span; - * }); - * ``` - * @since v19.9.0 - * @experimental - * @param fn callback using function to wrap a trace around - * @param position Zero-indexed argument position of expected callback - * @param context Shared object to correlate trace events through - * @param thisArg The receiver to be used for the function call - * @param args Optional arguments to pass to the function - * @return The return value of the given function - */ - traceCallback any>( - fn: Fn, - position?: number, - context?: ContextType, - thisArg?: any, - ...args: Parameters - ): void; - } -} -declare module 'node:diagnostics_channel' { - export * from 'diagnostics_channel'; -} diff --git a/packages/node/package.json b/packages/node/package.json index 6f0bec49c92a..e43d7b04a0ee 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -66,39 +66,39 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.1.0", - "@opentelemetry/core": "^2.1.0", - "@opentelemetry/instrumentation": "^0.204.0", - "@opentelemetry/instrumentation-amqplib": "0.51.0", - "@opentelemetry/instrumentation-connect": "0.48.0", - "@opentelemetry/instrumentation-dataloader": "0.22.0", - "@opentelemetry/instrumentation-express": "0.53.0", - "@opentelemetry/instrumentation-fs": "0.24.0", - "@opentelemetry/instrumentation-generic-pool": "0.48.0", - "@opentelemetry/instrumentation-graphql": "0.52.0", - "@opentelemetry/instrumentation-hapi": "0.51.0", - "@opentelemetry/instrumentation-http": "0.204.0", - "@opentelemetry/instrumentation-ioredis": "0.52.0", - "@opentelemetry/instrumentation-kafkajs": "0.14.0", - "@opentelemetry/instrumentation-knex": "0.49.0", - "@opentelemetry/instrumentation-koa": "0.52.0", - "@opentelemetry/instrumentation-lru-memoizer": "0.49.0", - "@opentelemetry/instrumentation-mongodb": "0.57.0", - "@opentelemetry/instrumentation-mongoose": "0.51.0", - "@opentelemetry/instrumentation-mysql": "0.50.0", - "@opentelemetry/instrumentation-mysql2": "0.51.0", - "@opentelemetry/instrumentation-pg": "0.57.0", - "@opentelemetry/instrumentation-redis": "0.53.0", - "@opentelemetry/instrumentation-tedious": "0.23.0", - "@opentelemetry/instrumentation-undici": "0.15.0", - "@opentelemetry/resources": "^2.1.0", - "@opentelemetry/sdk-trace-base": "^2.1.0", + "@opentelemetry/context-async-hooks": "^2.2.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/instrumentation-amqplib": "0.55.0", + "@opentelemetry/instrumentation-connect": "0.52.0", + "@opentelemetry/instrumentation-dataloader": "0.26.0", + "@opentelemetry/instrumentation-express": "0.57.0", + "@opentelemetry/instrumentation-fs": "0.28.0", + "@opentelemetry/instrumentation-generic-pool": "0.52.0", + "@opentelemetry/instrumentation-graphql": "0.56.0", + "@opentelemetry/instrumentation-hapi": "0.55.0", + "@opentelemetry/instrumentation-http": "0.208.0", + "@opentelemetry/instrumentation-ioredis": "0.56.0", + "@opentelemetry/instrumentation-kafkajs": "0.18.0", + "@opentelemetry/instrumentation-knex": "0.53.0", + "@opentelemetry/instrumentation-koa": "0.57.0", + "@opentelemetry/instrumentation-lru-memoizer": "0.53.0", + "@opentelemetry/instrumentation-mongodb": "0.61.0", + "@opentelemetry/instrumentation-mongoose": "0.55.0", + "@opentelemetry/instrumentation-mysql": "0.54.0", + "@opentelemetry/instrumentation-mysql2": "0.55.0", + "@opentelemetry/instrumentation-pg": "0.61.0", + "@opentelemetry/instrumentation-redis": "0.57.0", + "@opentelemetry/instrumentation-tedious": "0.27.0", + "@opentelemetry/instrumentation-undici": "0.19.0", + "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/sdk-trace-base": "^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@prisma/instrumentation": "6.15.0", + "@prisma/instrumentation": "6.19.0", "@sentry/core": "10.26.0", "@sentry/node-core": "10.26.0", "@sentry/opentelemetry": "10.26.0", - "import-in-the-middle": "^1.14.2", + "import-in-the-middle": "^2", "minimatch": "^9.0.0" }, "devDependencies": { diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json index 95b7f54cd000..86d73b8555b2 100644 --- a/packages/opentelemetry/package.json +++ b/packages/opentelemetry/package.json @@ -43,16 +43,16 @@ }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", - "@opentelemetry/core": "^1.30.1 || ^2.1.0", - "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", + "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0 || ^2.2.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0 || ^2.2.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0 || ^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0" }, "devDependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.1.0", - "@opentelemetry/core": "^2.1.0", - "@opentelemetry/sdk-trace-base": "^2.1.0", + "@opentelemetry/context-async-hooks": "^2.2.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/sdk-trace-base": "^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0" }, "scripts": { diff --git a/packages/react-router/package.json b/packages/react-router/package.json index a65bd845bdab..51ce7bb94122 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -46,8 +46,8 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/core": "^2.1.0", - "@opentelemetry/instrumentation": "^0.204.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/instrumentation": "^0.208.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@sentry/browser": "10.26.0", "@sentry/cli": "^2.58.2", diff --git a/packages/remix/package.json b/packages/remix/package.json index 181d6e23a63f..98bdd9a39c7c 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -65,7 +65,7 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/instrumentation": "^0.204.0", + "@opentelemetry/instrumentation": "^0.208.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@remix-run/router": "1.x", "@sentry/cli": "^2.58.2", diff --git a/packages/sveltekit/src/client/sdk.ts b/packages/sveltekit/src/client/sdk.ts index 5c3f482cb7d0..a6294ad25977 100644 --- a/packages/sveltekit/src/client/sdk.ts +++ b/packages/sveltekit/src/client/sdk.ts @@ -64,7 +64,7 @@ function getDefaultIntegrations(options: BrowserOptions): Integration[] | undefi * @returns the function that was previously on `window.fetch`. */ function switchToFetchProxy(): typeof fetch | undefined { - const globalWithSentryFetchProxy: WindowWithSentryFetchProxy = WINDOW as WindowWithSentryFetchProxy; + const globalWithSentryFetchProxy: WindowWithSentryFetchProxy = WINDOW; // eslint-disable-next-line @typescript-eslint/unbound-method const actualFetch = globalWithSentryFetchProxy.fetch; @@ -81,7 +81,7 @@ function switchToFetchProxy(): typeof fetch | undefined { * and puts our fetch proxy back onto `window._sentryFetchProxy`. */ function restoreFetch(actualFetch: typeof fetch): void { - const globalWithSentryFetchProxy: WindowWithSentryFetchProxy = WINDOW as WindowWithSentryFetchProxy; + const globalWithSentryFetchProxy: WindowWithSentryFetchProxy = WINDOW; // eslint-disable-next-line @typescript-eslint/unbound-method globalWithSentryFetchProxy._sentryFetchProxy = globalWithSentryFetchProxy.fetch; diff --git a/packages/vercel-edge/package.json b/packages/vercel-edge/package.json index d5c9ef78b8c1..1efd9c1af34c 100644 --- a/packages/vercel-edge/package.json +++ b/packages/vercel-edge/package.json @@ -40,13 +40,13 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/resources": "^2.1.0", + "@opentelemetry/resources": "^2.2.0", "@sentry/core": "10.26.0" }, "devDependencies": { "@edge-runtime/types": "3.0.1", - "@opentelemetry/core": "^2.1.0", - "@opentelemetry/sdk-trace-base": "^2.1.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/sdk-trace-base": "^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@sentry/opentelemetry": "10.26.0" }, diff --git a/packages/vercel-edge/src/vendored/abstract-async-hooks-context-manager.ts b/packages/vercel-edge/src/vendored/abstract-async-hooks-context-manager.ts index 72373afdebdf..3ec1934af3f7 100644 --- a/packages/vercel-edge/src/vendored/abstract-async-hooks-context-manager.ts +++ b/packages/vercel-edge/src/vendored/abstract-async-hooks-context-manager.ts @@ -32,10 +32,22 @@ /* eslint-disable @typescript-eslint/no-this-alias */ import type { Context, ContextManager } from '@opentelemetry/api'; -import type { EventEmitter } from 'events'; type Func = (...args: unknown[]) => T; +// Inline EventEmitter interface to avoid Node.js module dependency +// This prevents Node.js type leaks in edge runtime environments +interface EventEmitter { + addListener?(event: string, listener: Func ): this; + on?(event: string, listener: Func ): this; + once?(event: string, listener: Func ): this; + prependListener?(event: string, listener: Func ): this; + prependOnceListener?(event: string, listener: Func ): this; + removeListener?(event: string, listener: Func ): this; + off?(event: string, listener: Func ): this; + removeAllListeners?(event?: string): this; +} + /** * Store a map for each event of all original listeners and their "patched" * version. So when a listener is removed by the user, the corresponding diff --git a/packages/vercel-edge/src/vendored/async-local-storage-context-manager.ts b/packages/vercel-edge/src/vendored/async-local-storage-context-manager.ts index 3fd89f28af7c..257c6c27f041 100644 --- a/packages/vercel-edge/src/vendored/async-local-storage-context-manager.ts +++ b/packages/vercel-edge/src/vendored/async-local-storage-context-manager.ts @@ -28,10 +28,17 @@ import type { Context } from '@opentelemetry/api'; import { ROOT_CONTEXT } from '@opentelemetry/api'; import { debug, GLOBAL_OBJ } from '@sentry/core'; -import type { AsyncLocalStorage } from 'async_hooks'; import { DEBUG_BUILD } from '../debug-build'; import { AbstractAsyncHooksContextManager } from './abstract-async-hooks-context-manager'; +// Inline AsyncLocalStorage interface to avoid Node.js module dependency +// This prevents Node.js type leaks in edge runtime environments +interface AsyncLocalStorage { + getStore(): T | undefined; + run (store: T, callback: (...args: any[]) => R, ...args: any[]): R; + disable(): void; +} + export class AsyncLocalStorageContextManager extends AbstractAsyncHooksContextManager { private _asyncLocalStorage: AsyncLocalStorage ; @@ -46,12 +53,11 @@ export class AsyncLocalStorageContextManager extends AbstractAsyncHooksContextMa "Tried to register AsyncLocalStorage async context strategy in a runtime that doesn't support AsyncLocalStorage.", ); - // @ts-expect-error Vendored type shenanigans this._asyncLocalStorage = { getStore() { return undefined; }, - run(_store: unknown, callback: () => Context, ...args: unknown[]) { + run (_store: Context, callback: (...args: any[]) => R, ...args: any[]): R { return callback.apply(this, args); }, disable() { diff --git a/yarn.lock b/yarn.lock index 8687df6cfa53..41c2c2fe1486 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5959,17 +5959,10 @@ dependencies: "@octokit/openapi-types" "^18.0.0" -"@opentelemetry/api-logs@0.204.0": - version "0.204.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.204.0.tgz#c0285aa5c79625a1c424854393902d21732fd76b" - integrity sha512-DqxY8yoAaiBPivoJD4UtgrMS8gEmzZ5lnaxzPojzLVHBGqPxgWm4zcuvcUHZiqQ6kRX2Klel2r9y8cA2HAtqpw== - dependencies: - "@opentelemetry/api" "^1.3.0" - -"@opentelemetry/api-logs@0.57.2": - version "0.57.2" - resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz#d4001b9aa3580367b40fe889f3540014f766cc87" - integrity sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A== +"@opentelemetry/api-logs@0.208.0": + version "0.208.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz#56d3891010a1fa1cf600ba8899ed61b43ace511c" + integrity sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg== dependencies: "@opentelemetry/api" "^1.3.0" @@ -5978,277 +5971,260 @@ resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe" integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg== -"@opentelemetry/context-async-hooks@^2.1.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/context-async-hooks/-/context-async-hooks-2.1.0.tgz#de1de21d9536abfe73769f822b52a59a8c97b083" - integrity sha512-zOyetmZppnwTyPrt4S7jMfXiSX9yyfF0hxlA8B5oo2TtKl+/RGCy7fi4DrBfIf3lCPrkKsRBWZZD7RFojK7FDg== +"@opentelemetry/context-async-hooks@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/context-async-hooks/-/context-async-hooks-2.2.0.tgz#5465f6fad6350f52cf9d95a92907a3a464d50644" + integrity sha512-qRkLWiUEZNAmYapZ7KGS5C4OmBLcP/H2foXeOEaowYCR0wi89fHejrfYfbuLVCMLp/dWZXKvQusdbUEZjERfwQ== -"@opentelemetry/core@2.1.0", "@opentelemetry/core@^2.0.0", "@opentelemetry/core@^2.1.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-2.1.0.tgz#5539f04eb9e5245e000b0c3f77bdfaa07557e3a7" - integrity sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ== +"@opentelemetry/core@2.2.0", "@opentelemetry/core@^2.0.0", "@opentelemetry/core@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-2.2.0.tgz#2f857d7790ff160a97db3820889b5f4cade6eaee" + integrity sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw== dependencies: "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/instrumentation-amqplib@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.51.0.tgz#1779326433f1ab8a743bbf8e1957e1b1252cf036" - integrity sha512-XGmjYwjVRktD4agFnWBWQXo9SiYHKBxR6Ag3MLXwtLE4R99N3a08kGKM5SC1qOFKIELcQDGFEFT9ydXMH00Luw== +"@opentelemetry/instrumentation-amqplib@0.55.0": + version "0.55.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.55.0.tgz#4d1afc47e7690693efa690ed06fbda3acc585a2f" + integrity sha512-5ULoU8p+tWcQw5PDYZn8rySptGSLZHNX/7srqo2TioPnAAcvTy6sQFQXsNPrAnyRRtYGMetXVyZUy5OaX1+IfA== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.204.0" - "@opentelemetry/semantic-conventions" "^1.27.0" + "@opentelemetry/instrumentation" "^0.208.0" -"@opentelemetry/instrumentation-aws-sdk@0.59.0": - version "0.59.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.59.0.tgz#bd612836a6158f1773369a5646984b95f805273d" - integrity sha512-GN/9YGBMb//s0vnchM2jMCkCaIKDB/Piau72fcuqcDXNBffMgu+AA9vCHZD2umriciXLtXJ2GXTh2/yaaHwLIw== +"@opentelemetry/instrumentation-aws-sdk@0.64.0": + version "0.64.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.64.0.tgz#714508bde88be99c936f2191c7ba7f54ccdb5bc0" + integrity sha512-8+Y8IcUfME5jD03LISBcd9sFipgOon2uAoiLKSCroiGD6MPuwMzqlVvhlKSzq7uxwtZIhR6CTmjCpLsCHum59A== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.204.0" + "@opentelemetry/instrumentation" "^0.208.0" "@opentelemetry/semantic-conventions" "^1.34.0" -"@opentelemetry/instrumentation-connect@0.48.0": - version "0.48.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.48.0.tgz#4481c84315b33b54a67c6e787be0eb72a84b23b3" - integrity sha512-OMjc3SFL4pC16PeK+tDhwP7MRvDPalYCGSvGqUhX5rASkI2H0RuxZHOWElYeXkV0WP+70Gw6JHWac/2Zqwmhdw== +"@opentelemetry/instrumentation-connect@0.52.0": + version "0.52.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.52.0.tgz#60cde91c548e9da4528ae47fe69af41d05eeb485" + integrity sha512-GXPxfNB5szMbV3I9b7kNWSmQBoBzw7MT0ui6iU/p+NIzVx3a06Ri2cdQO7tG9EKb4aKSLmfX9Cw5cKxXqX6Ohg== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.204.0" + "@opentelemetry/instrumentation" "^0.208.0" "@opentelemetry/semantic-conventions" "^1.27.0" "@types/connect" "3.4.38" -"@opentelemetry/instrumentation-dataloader@0.22.0": - version "0.22.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.22.0.tgz#a34f8ac6ec18e8f1585dcd89f9f611570868d1a2" - integrity sha512-bXnTcwtngQsI1CvodFkTemrrRSQjAjZxqHVc+CJZTDnidT0T6wt3jkKhnsjU/Kkkc0lacr6VdRpCu2CUWa0OKw== +"@opentelemetry/instrumentation-dataloader@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.26.0.tgz#d10d22854ee8eac4471c82b8862b177a40f3bf8e" + integrity sha512-P2BgnFfTOarZ5OKPmYfbXfDFjQ4P9WkQ1Jji7yH5/WwB6Wm/knynAoA1rxbjWcDlYupFkyT0M1j6XLzDzy0aCA== dependencies: - "@opentelemetry/instrumentation" "^0.204.0" + "@opentelemetry/instrumentation" "^0.208.0" -"@opentelemetry/instrumentation-express@0.53.0": - version "0.53.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-express/-/instrumentation-express-0.53.0.tgz#902634e3de640bd4fa5370924397e716608ecb90" - integrity sha512-r/PBafQmFYRjuxLYEHJ3ze1iBnP2GDA1nXOSS6E02KnYNZAVjj6WcDA1MSthtdAUUK0XnotHvvWM8/qz7DMO5A== +"@opentelemetry/instrumentation-express@0.57.0": + version "0.57.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-express/-/instrumentation-express-0.57.0.tgz#7a2a7e90a84ad6c109f42c15acabdc7f6646a412" + integrity sha512-HAdx/o58+8tSR5iW+ru4PHnEejyKrAy9fYFhlEI81o10nYxrGahnMAHWiSjhDC7UQSY3I4gjcPgSKQz4rm/asg== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.204.0" + "@opentelemetry/instrumentation" "^0.208.0" "@opentelemetry/semantic-conventions" "^1.27.0" -"@opentelemetry/instrumentation-fs@0.24.0": - version "0.24.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.24.0.tgz#edf0f7418f6a1cdcbe135857ab75629e7d94b910" - integrity sha512-HjIxJ6CBRD770KNVaTdMXIv29Sjz4C1kPCCK5x1Ujpc6SNnLGPqUVyJYZ3LUhhnHAqdbrl83ogVWjCgeT4Q0yw== +"@opentelemetry/instrumentation-fs@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.28.0.tgz#6387fb7c19213afa31a2eb1b646d6356b95176bf" + integrity sha512-FFvg8fq53RRXVBRHZViP+EMxMR03tqzEGpuq55lHNbVPyFklSVfQBN50syPhK5UYYwaStx0eyCtHtbRreusc5g== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.204.0" + "@opentelemetry/instrumentation" "^0.208.0" -"@opentelemetry/instrumentation-generic-pool@0.48.0": - version "0.48.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.48.0.tgz#76fc08d76515db04f3833d730c5cb18cb0b237d4" - integrity sha512-TLv/On8pufynNR+pUbpkyvuESVASZZKMlqCm4bBImTpXKTpqXaJJ3o/MUDeMlM91rpen+PEv2SeyOKcHCSlgag== +"@opentelemetry/instrumentation-generic-pool@0.52.0": + version "0.52.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.52.0.tgz#12b57774ca3664edb9649687674320955e025906" + integrity sha512-ISkNcv5CM2IwvsMVL31Tl61/p2Zm2I2NAsYq5SSBgOsOndT0TjnptjufYVScCnD5ZLD1tpl4T3GEYULLYOdIdQ== dependencies: - "@opentelemetry/instrumentation" "^0.204.0" + "@opentelemetry/instrumentation" "^0.208.0" -"@opentelemetry/instrumentation-graphql@0.52.0": - version "0.52.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.52.0.tgz#a2d23a669bdd0a1b031f785fe447d5a34ac56343" - integrity sha512-3fEJ8jOOMwopvldY16KuzHbRhPk8wSsOTSF0v2psmOCGewh6ad+ZbkTx/xyUK9rUdUMWAxRVU0tFpj4Wx1vkPA== +"@opentelemetry/instrumentation-graphql@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.56.0.tgz#77464dec65efe5aa53d8787d0760534cf2e2a88f" + integrity sha512-IPvNk8AFoVzTAM0Z399t34VDmGDgwT6rIqCUug8P9oAGerl2/PEIYMPOl/rerPGu+q8gSWdmbFSjgg7PDVRd3Q== dependencies: - "@opentelemetry/instrumentation" "^0.204.0" + "@opentelemetry/instrumentation" "^0.208.0" -"@opentelemetry/instrumentation-hapi@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.51.0.tgz#879926dfbb2e1609cc8658392167b1456c75d9e0" - integrity sha512-qyf27DaFNL1Qhbo/da+04MSCw982B02FhuOS5/UF+PMhM61CcOiu7fPuXj8TvbqyReQuJFljXE6UirlvoT/62g== +"@opentelemetry/instrumentation-hapi@0.55.0": + version "0.55.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.55.0.tgz#a687b9bddfcc484f2cc85f022c123f83c19883a4" + integrity sha512-prqAkRf9e4eEpy4G3UcR32prKE8NLNlA90TdEU1UsghOTg0jUvs40Jz8LQWFEs5NbLbXHYGzB4CYVkCI8eWEVQ== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.204.0" + "@opentelemetry/instrumentation" "^0.208.0" "@opentelemetry/semantic-conventions" "^1.27.0" -"@opentelemetry/instrumentation-http@0.204.0": - version "0.204.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-http/-/instrumentation-http-0.204.0.tgz#faaf009b75e6a68729923b0a2a5270dc7d336f1d" - integrity sha512-1afJYyGRA4OmHTv0FfNTrTAzoEjPQUYgd+8ih/lX0LlZBnGio/O80vxA0lN3knsJPS7FiDrsDrWq25K7oAzbkw== +"@opentelemetry/instrumentation-http@0.208.0": + version "0.208.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-http/-/instrumentation-http-0.208.0.tgz#64fcc02bfbc80eb3bbb91cd3c7e0e24c695f2bef" + integrity sha512-rhmK46DRWEbQQB77RxmVXGyjs6783crXCnFjYQj+4tDH/Kpv9Rbg3h2kaNyp5Vz2emF1f9HOQQvZoHzwMWOFZQ== dependencies: - "@opentelemetry/core" "2.1.0" - "@opentelemetry/instrumentation" "0.204.0" + "@opentelemetry/core" "2.2.0" + "@opentelemetry/instrumentation" "0.208.0" "@opentelemetry/semantic-conventions" "^1.29.0" forwarded-parse "2.1.2" -"@opentelemetry/instrumentation-ioredis@0.52.0": - version "0.52.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.52.0.tgz#ca5d7b1a49798ed2d29a0f212a7ca5ef95c173c5" - integrity sha512-rUvlyZwI90HRQPYicxpDGhT8setMrlHKokCtBtZgYxQWRF5RBbG4q0pGtbZvd7kyseuHbFpA3I/5z7M8b/5ywg== +"@opentelemetry/instrumentation-ioredis@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.56.0.tgz#9b89cca6c3e440ae9e896f81dc6d2ab1dfee2581" + integrity sha512-XSWeqsd3rKSsT3WBz/JKJDcZD4QYElZEa0xVdX8f9dh4h4QgXhKRLorVsVkK3uXFbC2sZKAS2Ds+YolGwD83Dg== dependencies: - "@opentelemetry/instrumentation" "^0.204.0" - "@opentelemetry/redis-common" "^0.38.0" - "@opentelemetry/semantic-conventions" "^1.27.0" + "@opentelemetry/instrumentation" "^0.208.0" + "@opentelemetry/redis-common" "^0.38.2" -"@opentelemetry/instrumentation-kafkajs@0.14.0": - version "0.14.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.14.0.tgz#ffc30728b5845907d2c5b9f3883676c754ef4927" - integrity sha512-kbB5yXS47dTIdO/lfbbXlzhvHFturbux4EpP0+6H78Lk0Bn4QXiZQW7rmZY1xBCY16mNcCb8Yt0mhz85hTnSVA== +"@opentelemetry/instrumentation-kafkajs@0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.18.0.tgz#b836e6883afb7ca6df9fd3b6e024408dcc5e584b" + integrity sha512-KCL/1HnZN5zkUMgPyOxfGjLjbXjpd4odDToy+7c+UsthIzVLFf99LnfIBE8YSSrYE4+uS7OwJMhvhg3tWjqMBg== dependencies: - "@opentelemetry/instrumentation" "^0.204.0" + "@opentelemetry/instrumentation" "^0.208.0" "@opentelemetry/semantic-conventions" "^1.30.0" -"@opentelemetry/instrumentation-knex@0.49.0": - version "0.49.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.49.0.tgz#8c04c80c00ead5fbdf600cd2460dcd21b4069157" - integrity sha512-NKsRRT27fbIYL4Ix+BjjP8h4YveyKc+2gD6DMZbr5R5rUeDqfC8+DTfIt3c3ex3BIc5Vvek4rqHnN7q34ZetLQ== +"@opentelemetry/instrumentation-knex@0.53.0": + version "0.53.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.53.0.tgz#c2158c9259ff6789f6c2849bfd3c319edc0fcdf6" + integrity sha512-xngn5cH2mVXFmiT1XfQ1aHqq1m4xb5wvU6j9lSgLlihJ1bXzsO543cpDwjrZm2nMrlpddBf55w8+bfS4qDh60g== dependencies: - "@opentelemetry/instrumentation" "^0.204.0" + "@opentelemetry/instrumentation" "^0.208.0" "@opentelemetry/semantic-conventions" "^1.33.1" -"@opentelemetry/instrumentation-koa@0.52.0": - version "0.52.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.52.0.tgz#7266785ea85334366c3e50dc2b45468df438eb3f" - integrity sha512-JJSBYLDx/mNSy8Ibi/uQixu2rH0bZODJa8/cz04hEhRaiZQoeJ5UrOhO/mS87IdgVsHrnBOsZ6vDu09znupyuA== +"@opentelemetry/instrumentation-koa@0.57.0": + version "0.57.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.57.0.tgz#9a9edcde7de472f7f03904c00d31d87c6ee0ee42" + integrity sha512-3JS8PU/D5E3q295mwloU2v7c7/m+DyCqdu62BIzWt+3u9utjxC9QS7v6WmUNuoDN3RM+Q+D1Gpj13ERo+m7CGg== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.204.0" - "@opentelemetry/semantic-conventions" "^1.27.0" + "@opentelemetry/instrumentation" "^0.208.0" + "@opentelemetry/semantic-conventions" "^1.36.0" -"@opentelemetry/instrumentation-lru-memoizer@0.49.0": - version "0.49.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.49.0.tgz#6353b877628339e3f07189f4fb15919a73fe1503" - integrity sha512-ctXu+O/1HSadAxtjoEg2w307Z5iPyLOMM8IRNwjaKrIpNAthYGSOanChbk1kqY6zU5CrpkPHGdAT6jk8dXiMqw== +"@opentelemetry/instrumentation-lru-memoizer@0.53.0": + version "0.53.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.53.0.tgz#936c05263b719ee66999a9240b82fded044ebd2c" + integrity sha512-LDwWz5cPkWWr0HBIuZUjslyvijljTwmwiItpMTHujaULZCxcYE9eU44Qf/pbVC8TulT0IhZi+RoGvHKXvNhysw== dependencies: - "@opentelemetry/instrumentation" "^0.204.0" + "@opentelemetry/instrumentation" "^0.208.0" -"@opentelemetry/instrumentation-mongodb@0.57.0": - version "0.57.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.57.0.tgz#e697261b2eac05280134e1851b72c89d5b4b3da8" - integrity sha512-KD6Rg0KSHWDkik+qjIOWoksi1xqSpix8TSPfquIK1DTmd9OTFb5PHmMkzJe16TAPVEuElUW8gvgP59cacFcrMQ== +"@opentelemetry/instrumentation-mongodb@0.61.0": + version "0.61.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.61.0.tgz#4db130d537d630c3089115d2d214d29bcfb49f41" + integrity sha512-OV3i2DSoY5M/pmLk+68xr5RvkHU8DRB3DKMzYJdwDdcxeLs62tLbkmRyqJZsYf3Ht7j11rq35pHOWLuLzXL7pQ== dependencies: - "@opentelemetry/instrumentation" "^0.204.0" - "@opentelemetry/semantic-conventions" "^1.27.0" + "@opentelemetry/instrumentation" "^0.208.0" -"@opentelemetry/instrumentation-mongoose@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.51.0.tgz#688e9f3448e3d0979c4aaab5b566e14f30a1aa72" - integrity sha512-gwWaAlhhV2By7XcbyU3DOLMvzsgeaymwP/jktDC+/uPkCmgB61zurwqOQdeiRq9KAf22Y2dtE5ZLXxytJRbEVA== +"@opentelemetry/instrumentation-mongoose@0.55.0": + version "0.55.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.55.0.tgz#e6851aba996b23b9709143c2b640084e92313dea" + integrity sha512-5afj0HfF6aM6Nlqgu6/PPHFk8QBfIe3+zF9FGpX76jWPS0/dujoEYn82/XcLSaW5LPUDW8sni+YeK0vTBNri+w== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.204.0" - "@opentelemetry/semantic-conventions" "^1.27.0" + "@opentelemetry/instrumentation" "^0.208.0" -"@opentelemetry/instrumentation-mysql2@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.51.0.tgz#7eec3a0b9e4b27759df5df1c82eaedcf34b27528" - integrity sha512-zT2Wg22Xn43RyfU3NOUmnFtb5zlDI0fKcijCj9AcK9zuLZ4ModgtLXOyBJSSfO+hsOCZSC1v/Fxwj+nZJFdzLQ== +"@opentelemetry/instrumentation-mysql2@0.55.0": + version "0.55.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.55.0.tgz#a0957590aa8d402d1debd10e42d7b5da359164ec" + integrity sha512-0cs8whQG55aIi20gnK8B7cco6OK6N+enNhW0p5284MvqJ5EPi+I1YlWsWXgzv/V2HFirEejkvKiI4Iw21OqDWg== dependencies: - "@opentelemetry/instrumentation" "^0.204.0" - "@opentelemetry/semantic-conventions" "^1.27.0" - "@opentelemetry/sql-common" "^0.41.0" + "@opentelemetry/instrumentation" "^0.208.0" + "@opentelemetry/semantic-conventions" "^1.33.0" + "@opentelemetry/sql-common" "^0.41.2" -"@opentelemetry/instrumentation-mysql@0.50.0": - version "0.50.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.50.0.tgz#25de9de05191cecf8b01df379544eba50fa6f548" - integrity sha512-duKAvMRI3vq6u9JwzIipY9zHfikN20bX05sL7GjDeLKr2qV0LQ4ADtKST7KStdGcQ+MTN5wghWbbVdLgNcB3rA== +"@opentelemetry/instrumentation-mysql@0.54.0": + version "0.54.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.54.0.tgz#6181ae097a2b5501049c518fe90393e1f136341d" + integrity sha512-bqC1YhnwAeWmRzy1/Xf9cDqxNG2d/JDkaxnqF5N6iJKN1eVWI+vg7NfDkf52/Nggp3tl1jcC++ptC61BD6738A== dependencies: - "@opentelemetry/instrumentation" "^0.204.0" - "@opentelemetry/semantic-conventions" "^1.27.0" + "@opentelemetry/instrumentation" "^0.208.0" "@types/mysql" "2.15.27" -"@opentelemetry/instrumentation-nestjs-core@0.50.0": - version "0.50.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.50.0.tgz#f803bbeb6c972ac8f0685885cca7f6e5a4e09056" - integrity sha512-10u2Gjw260W8vdUem6pM7ENrb8i+UAyrgouhjN7HRdQYh9rcit51tRhgrI52fxTsRjrrBNIItHkX0YM8WnEU2w== +"@opentelemetry/instrumentation-nestjs-core@0.55.0": + version "0.55.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.55.0.tgz#820391be7ed2b699b49fef55b78619832ac0e0ae" + integrity sha512-JFLNhbbEGnnQrMKOYoXx0nNk5N9cPeghu4xP/oup40a7VaSeYruyOiFbg9nkbS4ZQiI8aMuRqUT3Mo4lQjKEKg== dependencies: - "@opentelemetry/instrumentation" "^0.204.0" + "@opentelemetry/instrumentation" "^0.208.0" "@opentelemetry/semantic-conventions" "^1.30.0" -"@opentelemetry/instrumentation-pg@0.57.0": - version "0.57.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.57.0.tgz#346cb613ccd1100221cef9692271468a3fe92eb0" - integrity sha512-dWLGE+r5lBgm2A8SaaSYDE3OKJ/kwwy5WLyGyzor8PLhUL9VnJRiY6qhp4njwhnljiLtzeffRtG2Mf/YyWLeTw== +"@opentelemetry/instrumentation-pg@0.61.0": + version "0.61.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.61.0.tgz#c755d00dba640e229fe50f817423dcf3376957ab" + integrity sha512-UeV7KeTnRSM7ECHa3YscoklhUtTQPs6V6qYpG283AB7xpnPGCUCUfECFT9jFg6/iZOQTt3FHkB1wGTJCNZEvPw== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.204.0" + "@opentelemetry/instrumentation" "^0.208.0" "@opentelemetry/semantic-conventions" "^1.34.0" - "@opentelemetry/sql-common" "^0.41.0" - "@types/pg" "8.15.5" + "@opentelemetry/sql-common" "^0.41.2" + "@types/pg" "8.15.6" "@types/pg-pool" "2.0.6" -"@opentelemetry/instrumentation-redis@0.53.0": - version "0.53.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.53.0.tgz#826cfeacebaf7ce571bb932ad410f23caf170b9c" - integrity sha512-WUHV8fr+8yo5RmzyU7D5BIE1zwiaNQcTyZPwtxlfr7px6NYYx7IIpSihJK7WA60npWynfxxK1T67RAVF0Gdfjg== +"@opentelemetry/instrumentation-redis@0.57.0": + version "0.57.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.57.0.tgz#c6996eb8ace9cb16cf5be3db3a6b0fb599f47fab" + integrity sha512-bCxTHQFXzrU3eU1LZnOZQ3s5LURxQPDlU3/upBzlWY77qOI1GZuGofazj3jtzjctMJeBEJhNwIFEgRPBX1kp/Q== dependencies: - "@opentelemetry/instrumentation" "^0.204.0" - "@opentelemetry/redis-common" "^0.38.0" + "@opentelemetry/instrumentation" "^0.208.0" + "@opentelemetry/redis-common" "^0.38.2" "@opentelemetry/semantic-conventions" "^1.27.0" -"@opentelemetry/instrumentation-tedious@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.23.0.tgz#a781de2cb33ff71ef65bbefba11c9fe2d79c4b32" - integrity sha512-3TMTk/9VtlRonVTaU4tCzbg4YqW+Iq/l5VnN2e5whP6JgEg/PKfrGbqQ+CxQWNLfLaQYIUgEZqAn5gk/inh1uQ== +"@opentelemetry/instrumentation-tedious@0.27.0": + version "0.27.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.27.0.tgz#f4ba662fd17edde80f1b14d0dc4c42c7fa4a3139" + integrity sha512-jRtyUJNZppPBjPae4ZjIQ2eqJbcRaRfJkr0lQLHFmOU/no5A6e9s1OHLd5XZyZoBJ/ymngZitanyRRA5cniseA== dependencies: - "@opentelemetry/instrumentation" "^0.204.0" - "@opentelemetry/semantic-conventions" "^1.27.0" + "@opentelemetry/instrumentation" "^0.208.0" "@types/tedious" "^4.0.14" -"@opentelemetry/instrumentation-undici@0.15.0": - version "0.15.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.15.0.tgz#c8193a162d4abe61c2fd247912e0cb8c0c3bc10c" - integrity sha512-sNFGA/iCDlVkNjzTzPRcudmI11vT/WAfAguRdZY9IspCw02N4WSC72zTuQhSMheh2a1gdeM9my1imnKRvEEvEg== +"@opentelemetry/instrumentation-undici@0.19.0": + version "0.19.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.19.0.tgz#a9db59a7630261269239d17d2990d406e2ecddf8" + integrity sha512-Pst/RhR61A2OoZQZkn6OLpdVpXp6qn3Y92wXa6umfJe9rV640r4bc6SWvw4pPN6DiQqPu2c8gnSSZPDtC6JlpQ== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.204.0" - -"@opentelemetry/instrumentation@0.204.0", "@opentelemetry/instrumentation@^0.204.0": - version "0.204.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.204.0.tgz#587c104c02c9ccb38932ce508d9c81514ec7a7c4" - integrity sha512-vV5+WSxktzoMP8JoYWKeopChy6G3HKk4UQ2hESCRDUUTZqQ3+nM3u8noVG0LmNfRWwcFBnbZ71GKC7vaYYdJ1g== - dependencies: - "@opentelemetry/api-logs" "0.204.0" - import-in-the-middle "^1.8.1" - require-in-the-middle "^7.1.1" - -"@opentelemetry/instrumentation@^0.52.0 || ^0.53.0 || ^0.54.0 || ^0.55.0 || ^0.56.0 || ^0.57.0": - version "0.57.2" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz#8924549d7941ba1b5c6f04d5529cf48330456d1d" - integrity sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg== - dependencies: - "@opentelemetry/api-logs" "0.57.2" - "@types/shimmer" "^1.2.0" - import-in-the-middle "^1.8.1" - require-in-the-middle "^7.1.1" - semver "^7.5.2" - shimmer "^1.2.1" - -"@opentelemetry/redis-common@^0.38.0": - version "0.38.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/redis-common/-/redis-common-0.38.0.tgz#87d2a792dcbcf466a41bb7dfb8a7cd094d643d0b" - integrity sha512-4Wc0AWURII2cfXVVoZ6vDqK+s5n4K5IssdrlVrvGsx6OEOKdghKtJZqXAHWFiZv4nTDLH2/2fldjIHY8clMOjQ== - -"@opentelemetry/resources@2.1.0", "@opentelemetry/resources@^2.1.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-2.1.0.tgz#11772e732af4f27953cf55567a6630d8b4d8282d" - integrity sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw== + "@opentelemetry/instrumentation" "^0.208.0" + "@opentelemetry/semantic-conventions" "^1.24.0" + +"@opentelemetry/instrumentation@0.208.0", "@opentelemetry/instrumentation@>=0.52.0 <1", "@opentelemetry/instrumentation@^0.208.0": + version "0.208.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz#d764f8e4329dad50804e2e98f010170c14c4ce8f" + integrity sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA== + dependencies: + "@opentelemetry/api-logs" "0.208.0" + import-in-the-middle "^2.0.0" + require-in-the-middle "^8.0.0" + +"@opentelemetry/redis-common@^0.38.2": + version "0.38.2" + resolved "https://registry.yarnpkg.com/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz#cefa4f3e79db1cd54f19e233b7dfb56621143955" + integrity sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA== + +"@opentelemetry/resources@2.2.0", "@opentelemetry/resources@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-2.2.0.tgz#b90a950ad98551295b76ea8a0e7efe45a179badf" + integrity sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A== dependencies: - "@opentelemetry/core" "2.1.0" + "@opentelemetry/core" "2.2.0" "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/sdk-trace-base@^2.1.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.1.0.tgz#9d31474824e9ed215f94bf71260d5321f64d402a" - integrity sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ== +"@opentelemetry/sdk-trace-base@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz#ddef9a0afd01a623d8625a3529f2137b05e67d0b" + integrity sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw== dependencies: - "@opentelemetry/core" "2.1.0" - "@opentelemetry/resources" "2.1.0" + "@opentelemetry/core" "2.2.0" + "@opentelemetry/resources" "2.2.0" "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/semantic-conventions@^1.27.0", "@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.30.0", "@opentelemetry/semantic-conventions@^1.33.1", "@opentelemetry/semantic-conventions@^1.34.0", "@opentelemetry/semantic-conventions@^1.37.0": - version "1.37.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.37.0.tgz#aa2b4fa0b910b66a050c5ddfcac1d262e91a321a" - integrity sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA== +"@opentelemetry/semantic-conventions@^1.24.0", "@opentelemetry/semantic-conventions@^1.27.0", "@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.30.0", "@opentelemetry/semantic-conventions@^1.33.0", "@opentelemetry/semantic-conventions@^1.33.1", "@opentelemetry/semantic-conventions@^1.34.0", "@opentelemetry/semantic-conventions@^1.36.0", "@opentelemetry/semantic-conventions@^1.37.0": + version "1.38.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.38.0.tgz#8b5f415395a7ddb7c8e0c7932171deb9278df1a3" + integrity sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg== -"@opentelemetry/sql-common@^0.41.0": - version "0.41.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/sql-common/-/sql-common-0.41.0.tgz#7ddef1ea7fb6338dcca8a9d2485c7dfd53c076b4" - integrity sha512-pmzXctVbEERbqSfiAgdes9Y63xjoOyXcD7B6IXBkVb+vbM7M9U98mn33nGXxPf4dfYR0M+vhcKRZmbSJ7HfqFA== +"@opentelemetry/sql-common@^0.41.2": + version "0.41.2" + resolved "https://registry.yarnpkg.com/@opentelemetry/sql-common/-/sql-common-0.41.2.tgz#7f4a14166cfd6c9ffe89096db1cc75eaf6443b19" + integrity sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ== dependencies: "@opentelemetry/core" "^2.0.0" @@ -6456,12 +6432,12 @@ dependencies: "@prisma/debug" "6.15.0" -"@prisma/instrumentation@6.15.0": - version "6.15.0" - resolved "https://registry.yarnpkg.com/@prisma/instrumentation/-/instrumentation-6.15.0.tgz#40b066dc6b1ea621aa5ae0fd6d54319550b7d8c9" - integrity sha512-6TXaH6OmDkMOQvOxwLZ8XS51hU2v4A3vmE2pSijCIiGRJYyNeMcL6nMHQMyYdZRD8wl7LF3Wzc+AMPMV/9Oo7A== +"@prisma/instrumentation@6.19.0": + version "6.19.0" + resolved "https://registry.yarnpkg.com/@prisma/instrumentation/-/instrumentation-6.19.0.tgz#46d15adc8bc4a5a3167032eea6d0a7aa64fb7d93" + integrity sha512-QcuYy25pkXM8BJ37wVFBO7Zh34nyRV1GOb2n3lPkkbRYfl4hWl3PTcImP41P0KrzVXfa/45p6eVCos27x3exIg== dependencies: - "@opentelemetry/instrumentation" "^0.52.0 || ^0.53.0 || ^0.54.0 || ^0.55.0 || ^0.56.0 || ^0.57.0" + "@opentelemetry/instrumentation" ">=0.52.0 <1" "@protobuf-ts/plugin-framework@^2.0.7", "@protobuf-ts/plugin-framework@^2.9.4": version "2.9.4" @@ -8947,10 +8923,10 @@ dependencies: "@types/pg" "*" -"@types/pg@*", "@types/pg@8.15.5", "@types/pg@^8.6.5": - version "8.15.5" - resolved "https://registry.yarnpkg.com/@types/pg/-/pg-8.15.5.tgz#ef43e0f33b62dac95cae2f042888ec7980b30c09" - integrity sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ== +"@types/pg@*", "@types/pg@8.15.6", "@types/pg@^8.6.5": + version "8.15.6" + resolved "https://registry.yarnpkg.com/@types/pg/-/pg-8.15.6.tgz#4df7590b9ac557cbe5479e0074ec1540cbddad9b" + integrity sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ== dependencies: "@types/node" "*" pg-protocol "*" @@ -9097,11 +9073,6 @@ "@types/mime" "*" "@types/node" "*" -"@types/shimmer@^1.2.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@types/shimmer/-/shimmer-1.2.0.tgz#9b706af96fa06416828842397a70dfbbf1c14ded" - integrity sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg== - "@types/sinon@^17.0.3": version "17.0.3" resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-17.0.3.tgz#9aa7e62f0a323b9ead177ed23a36ea757141a5fa" @@ -19113,10 +19084,10 @@ import-fresh@^3.0.0, import-fresh@^3.2.1: parent-module "^1.0.0" resolve-from "^4.0.0" -import-in-the-middle@^1.14.2, import-in-the-middle@^1.8.1: - version "1.14.2" - resolved "https://registry.yarnpkg.com/import-in-the-middle/-/import-in-the-middle-1.14.2.tgz#283661625a88ff7c0462bd2984f77715c3bc967c" - integrity sha512-5tCuY9BV8ujfOpwtAGgsTx9CGUapcFMEEyByLv1B+v2+6DhAcw+Zr0nhQT7uwaZ7DiourxFEscghOR8e1aPLQw== +import-in-the-middle@^2, import-in-the-middle@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/import-in-the-middle/-/import-in-the-middle-2.0.0.tgz#295948cee94d0565314824c6bd75379d13e5b1a5" + integrity sha512-yNZhyQYqXpkT0AKq3F3KLasUSK4fHvebNH5hOsKQw2dhGSALvQ4U0BqUc5suziKvydO5u5hgN2hy1RJaho8U5A== dependencies: acorn "^8.14.0" acorn-import-attributes "^1.9.5" @@ -26741,14 +26712,13 @@ require-from-string@^2.0.2: resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== -require-in-the-middle@^7.1.1: - version "7.2.0" - resolved "https://registry.yarnpkg.com/require-in-the-middle/-/require-in-the-middle-7.2.0.tgz#b539de8f00955444dc8aed95e17c69b0a4f10fcf" - integrity sha512-3TLx5TGyAY6AOqLBoXmHkNql0HIf2RGbuMgCDT2WO/uGVAPJs6h7Kl+bN6TIZGd9bWhWPwnDnTHGtW8Iu77sdw== +require-in-the-middle@^8.0.0: + version "8.0.1" + resolved "https://registry.yarnpkg.com/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz#dbde2587f669398626d56b20c868ab87bf01cce4" + integrity sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ== dependencies: - debug "^4.1.1" + debug "^4.3.5" module-details-from-path "^1.0.3" - resolve "^1.22.1" require-package-name@^2.0.1: version "2.0.1" @@ -27487,7 +27457,7 @@ semver@^6.0.0, semver@^6.1.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0, semve resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.0.0, semver@^7.1.1, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.0, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.2, semver@^7.6.3, semver@^7.7.2: +semver@^7.0.0, semver@^7.1.1, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.0, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.2, semver@^7.6.3, semver@^7.7.2: version "7.7.2" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== @@ -27773,11 +27743,6 @@ shikiji@^0.9.12: dependencies: shikiji-core "0.9.19" -shimmer@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/shimmer/-/shimmer-1.2.1.tgz#610859f7de327b587efebf501fb43117f9aff337" - integrity sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw== - side-channel-list@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" From 5e7cd0687fc242201f7f1dbf36de5ae686e9ca54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Bignon?= Date: Mon, 24 Nov 2025 10:02:04 +0100 Subject: [PATCH 22/32] feat(node): Add tracing support for AzureOpenAI (#18281) This pull request adds the support to Azure OpenAI client in addition to the existing support of the vanilla OpenAI client. Fixes issue #18280 --- .../tracing/openai/scenario-azure-openai.mjs | 64 +++++++++++++++++++ .../suites/tracing/openai/test.ts | 47 ++++++++++++++ .../openai/v6/scenario-azure-openai.mjs | 64 +++++++++++++++++++ .../suites/tracing/openai/v6/test.ts | 58 +++++++++++++++++ .../tracing/openai/instrumentation.ts | 22 +++++-- 5 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/scenario-azure-openai.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/v6/scenario-azure-openai.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-azure-openai.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-azure-openai.mjs new file mode 100644 index 000000000000..6d519ae8b313 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-azure-openai.mjs @@ -0,0 +1,64 @@ +import express from 'express'; +import { AzureOpenAI } from 'openai'; + +function startMockOpenAiServer() { + const app = express(); + app.use(express.json()); + + app.post('/azureopenai/deployments/:model/chat/completions', (req, res) => { + res.send({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model: req.body.model, + system_fingerprint: 'fp_44709d6fcb', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'Hello from OpenAI mock!', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 15, + total_tokens: 25, + }, + }); + }); + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockOpenAiServer(); + + const client = new AzureOpenAI({ + baseURL: `http://localhost:${server.address().port}/azureopenai`, + apiKey: 'mock-api-key', + apiVersion: '2024-02-15-preview', + }); + + const response = await client.chat.completions.create({ + model: 'gpt-3.5-turbo', + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'What is the capital of France?' }, + ], + temperature: 0.7, + max_tokens: 100, + }); + + // eslint-disable-next-line no-console + console.log(JSON.stringify(response)); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts index 116c3a6208fa..a0436d9e5a8b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -501,6 +501,53 @@ describe('OpenAI integration', () => { }); }); + createEsmAndCjsTests(__dirname, 'scenario-azure-openai.mjs', 'instrument.mjs', (createRunner, test) => { + test('it works with Azure OpenAI', async () => { + await createRunner() + // First the span that our mock express server is emitting, unrelated to this test + .expect({ + transaction: { + transaction: 'POST /azureopenai/deployments/:model/chat/completions', + }, + }) + .expect({ + transaction: { + transaction: 'chat gpt-3.5-turbo', + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-3.5-turbo', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.response.model': 'gpt-3.5-turbo', + 'gen_ai.response.id': 'chatcmpl-mock123', + 'gen_ai.response.finish_reasons': '["stop"]', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + 'openai.response.id': 'chatcmpl-mock123', + 'openai.response.model': 'gpt-3.5-turbo', + 'openai.response.timestamp': '2023-03-01T06:31:28.000Z', + 'openai.usage.completion_tokens': 15, + 'openai.usage.prompt_tokens': 10, + }, + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'ok', + }, + }, + }, + }) + .start() + .completed(); + }); + }); + createEsmAndCjsTests( __dirname, 'truncation/scenario-message-truncation-completions.mjs', diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v6/scenario-azure-openai.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/v6/scenario-azure-openai.mjs new file mode 100644 index 000000000000..6d519ae8b313 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v6/scenario-azure-openai.mjs @@ -0,0 +1,64 @@ +import express from 'express'; +import { AzureOpenAI } from 'openai'; + +function startMockOpenAiServer() { + const app = express(); + app.use(express.json()); + + app.post('/azureopenai/deployments/:model/chat/completions', (req, res) => { + res.send({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model: req.body.model, + system_fingerprint: 'fp_44709d6fcb', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'Hello from OpenAI mock!', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 15, + total_tokens: 25, + }, + }); + }); + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockOpenAiServer(); + + const client = new AzureOpenAI({ + baseURL: `http://localhost:${server.address().port}/azureopenai`, + apiKey: 'mock-api-key', + apiVersion: '2024-02-15-preview', + }); + + const response = await client.chat.completions.create({ + model: 'gpt-3.5-turbo', + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'What is the capital of France?' }, + ], + temperature: 0.7, + max_tokens: 100, + }); + + // eslint-disable-next-line no-console + console.log(JSON.stringify(response)); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts index 053f3066a1b0..4929325c6790 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts @@ -562,4 +562,62 @@ describe('OpenAI integration (V6)', () => { }, }, ); + + createEsmAndCjsTests( + __dirname, + 'scenario-azure-openai.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('it works with Azure OpenAI (v6)', async () => { + await createRunner() + // First the span that our mock express server is emitting, unrelated to this test + .expect({ + transaction: { + transaction: 'POST /azureopenai/deployments/:model/chat/completions', + }, + }) + .expect({ + transaction: { + transaction: 'chat gpt-3.5-turbo', + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-3.5-turbo', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.response.model': 'gpt-3.5-turbo', + 'gen_ai.response.id': 'chatcmpl-mock123', + 'gen_ai.response.finish_reasons': '["stop"]', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + 'openai.response.id': 'chatcmpl-mock123', + 'openai.response.model': 'gpt-3.5-turbo', + 'openai.response.timestamp': '2023-03-01T06:31:28.000Z', + 'openai.usage.completion_tokens': 15, + 'openai.usage.prompt_tokens': 10, + }, + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'ok', + }, + }, + }, + }) + .start() + .completed(); + }); + }, + { + additionalDependencies: { + openai: '6.0.0', + express: 'latest', + }, + }, + ); }); diff --git a/packages/node/src/integrations/tracing/openai/instrumentation.ts b/packages/node/src/integrations/tracing/openai/instrumentation.ts index e0682185ff0a..b1a577f9a5f4 100644 --- a/packages/node/src/integrations/tracing/openai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/openai/instrumentation.ts @@ -25,6 +25,7 @@ export interface OpenAiIntegration extends Integration { interface PatchedModuleExports { [key: string]: unknown; OpenAI: abstract new (...args: unknown[]) => OpenAiClient; + AzureOpenAI?: abstract new (...args: unknown[]) => OpenAiClient; } /** @@ -56,10 +57,23 @@ export class SentryOpenAiInstrumentation extends InstrumentationBase Date: Mon, 24 Nov 2025 09:11:08 +0000 Subject: [PATCH 23/32] ref(react): Add more guarding against wildcards in lazy route transactions (#18155) Building on top of #17962 Added a few more checks to make sure non-resolved (wildcard) routes are not reported in lazy route pageloads / navigations. - Improved `patchSpanEnd` with a user-configurable wait timeout for potentially slow route resolution. Named this option as `lazyRouteTimeout` and it's defaulted as `idleTimeout` * 3. It may conditionally delay reporting (if the route resolution is still not done by the end of the timeout), but will prevent prematurely sent lazy-route transactions inside that window. - Added extra checks on `updateNavigationSpan` and `handleNavigation` for whether any wildcard still exists in a lazy-route, so they are still marked as open to full resolution. We keep track of pending lazy-route resolutions inside `pendingLazyRouteLoads` - Added a final attempt to update the transaction name with fully-resolved route when the pending resolution is done. Any of these should not affect the behaviour of non-lazy route usage --------- Co-authored-by: Sigrid <32902192+s1gr1d@users.noreply.github.com> --- .../react-router-7-lazy-routes/src/index.tsx | 63 +- .../src/pages/Deep.tsx | 12 + .../src/pages/DelayedLazyRoute.tsx | 50 + .../src/pages/Index.tsx | 12 + .../src/pages/deep/Level1Routes.tsx | 11 + .../src/pages/deep/Level2Routes.tsx | 14 + .../src/pages/deep/Level3.tsx | 13 + .../tests/timeout-behaviour.test.ts | 126 +++ .../tests/transactions.test.ts | 295 ++++++ .../src/reactrouter-compat-utils/index.ts | 1 + .../instrumentation.tsx | 846 ++++++++++------ .../src/reactrouter-compat-utils/utils.ts | 5 + .../instrumentation.test.tsx | 943 +++++++++++++++++- .../reactrouter-compat-utils/utils.test.ts | 35 + 14 files changed, 2114 insertions(+), 312 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Deep.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/DelayedLazyRoute.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level1Routes.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level2Routes.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level3.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/timeout-behaviour.test.ts diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx index 521048fd18f4..7787b60be398 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx @@ -2,7 +2,6 @@ import * as Sentry from '@sentry/react'; import React from 'react'; import ReactDOM from 'react-dom/client'; import { - Navigate, PatchRoutesOnNavigationFunction, RouterProvider, createBrowserRouter, @@ -12,6 +11,49 @@ import { useNavigationType, } from 'react-router-dom'; import Index from './pages/Index'; +import Deep from './pages/Deep'; + +function getRuntimeConfig(): { lazyRouteTimeout?: number; idleTimeout?: number } { + if (typeof window === 'undefined') { + return {}; + } + + try { + const url = new URL(window.location.href); + const timeoutParam = url.searchParams.get('timeout'); + const idleTimeoutParam = url.searchParams.get('idleTimeout'); + + let lazyRouteTimeout: number | undefined = undefined; + if (timeoutParam) { + if (timeoutParam === 'Infinity') { + lazyRouteTimeout = Infinity; + } else { + const parsed = parseInt(timeoutParam, 10); + if (!isNaN(parsed)) { + lazyRouteTimeout = parsed; + } + } + } + + let idleTimeout: number | undefined = undefined; + if (idleTimeoutParam) { + const parsed = parseInt(idleTimeoutParam, 10); + if (!isNaN(parsed)) { + idleTimeout = parsed; + } + } + + return { + lazyRouteTimeout, + idleTimeout, + }; + } catch (error) { + console.warn('Failed to read runtime config, falling back to defaults', error); + return {}; + } +} + +const runtimeConfig = getRuntimeConfig(); Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions @@ -25,6 +67,8 @@ Sentry.init({ matchRoutes, trackFetchStreamPerformance: true, enableAsyncRouteHandlers: true, + lazyRouteTimeout: runtimeConfig.lazyRouteTimeout, + idleTimeout: runtimeConfig.idleTimeout, }), ], // We recommend adjusting this value in production, or using tracesSampler @@ -66,8 +110,21 @@ const router = sentryCreateBrowserRouter( element: <>Hello World>, }, { - path: '*', - element: , + path: '/delayed-lazy/:id', + lazy: async () => { + // Simulate slow lazy route loading (400ms delay) + await new Promise(resolve => setTimeout(resolve, 400)); + return { + Component: (await import('./pages/DelayedLazyRoute')).default, + }; + }, + }, + { + path: '/deep', + element: , + handle: { + lazyChildren: () => import('./pages/deep/Level1Routes').then(module => module.level2Routes), + }, }, ], { diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Deep.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Deep.tsx new file mode 100644 index 000000000000..c68f7b781e77 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Deep.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { Outlet } from 'react-router-dom'; + +export default function Deep() { + return ( + ++ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/DelayedLazyRoute.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/DelayedLazyRoute.tsx new file mode 100644 index 000000000000..41e5ba5463be --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/DelayedLazyRoute.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Link, useParams, useLocation, useSearchParams } from 'react-router-dom'; + +const DelayedLazyRoute = () => { + const { id } = useParams<{ id: string }>(); + const location = useLocation(); + const [searchParams] = useSearchParams(); + const view = searchParams.get('view') || 'none'; + const source = searchParams.get('source') || 'none'; + + return ( +Deep Route Root
+You are at the deep route root
++ ++ ); +}; + +export default DelayedLazyRoute; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Index.tsx index 3053aa57b887..21b965f571f3 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Index.tsx @@ -19,6 +19,18 @@ const Index = () => { Navigate to Long Running Lazy Route +Delayed Lazy Route
+ID: {id}
+{location.pathname}
+{location.search}
+{location.hash}
+View: {view}
+Source: {source}
+ + +
+ + Navigate to Delayed Lazy Parameterized Route + +
+ + Navigate to Delayed Lazy with Query Param + +
+ + Navigate to Deep Nested Route (3 levels, 900ms total) + > ); }; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level1Routes.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level1Routes.tsx new file mode 100644 index 000000000000..0e0887b8850b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level1Routes.tsx @@ -0,0 +1,11 @@ +// Delay: 300ms before module loads +await new Promise(resolve => setTimeout(resolve, 300)); + +export const level2Routes = [ + { + path: 'level2', + handle: { + lazyChildren: () => import('./Level2Routes').then(module => module.level3Routes), + }, + }, +]; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level2Routes.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level2Routes.tsx new file mode 100644 index 000000000000..43671e1b7eee --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level2Routes.tsx @@ -0,0 +1,14 @@ +// Delay: 300ms before module loads +await new Promise(resolve => setTimeout(resolve, 300)); + +export const level3Routes = [ + { + path: 'level3/:id', + lazy: async () => { + await new Promise(resolve => setTimeout(resolve, 300)); + return { + Component: (await import('./Level3')).default, + }; + }, + }, +]; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level3.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level3.tsx new file mode 100644 index 000000000000..e44ecc7da655 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level3.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; + +export default function Level3() { + const { id } = useParams(); + return ( +++ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/timeout-behaviour.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/timeout-behaviour.test.ts new file mode 100644 index 000000000000..281ebc88e52c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/timeout-behaviour.test.ts @@ -0,0 +1,126 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('lazyRouteTimeout: Routes load within timeout window', async ({ page }) => { + const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction.includes('deep') + ); + }); + + // Route takes ~900ms, timeout allows 1050ms (50 + 1000) + // Routes will load in time → parameterized name + await page.goto('/?idleTimeout=50&timeout=1000'); + + const navigationLink = page.locator('id=navigation-to-deep'); + await expect(navigationLink).toBeVisible(); + await navigationLink.click(); + + const event = await transactionPromise; + + // Should get full parameterized route + expect(event.transaction).toBe('/deep/level2/level3/:id'); + expect(event.contexts?.trace?.data?.['sentry.source']).toBe('route'); + expect(event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); +}); + +test('lazyRouteTimeout: Infinity timeout always waits for routes', async ({ page }) => { + const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction.includes('deep') + ); + }); + + // Infinity timeout → waits as long as possible (capped at finalTimeout to prevent indefinite hangs) + await page.goto('/?idleTimeout=50&timeout=Infinity'); + + const navigationLink = page.locator('id=navigation-to-deep'); + await expect(navigationLink).toBeVisible(); + await navigationLink.click(); + + const event = await transactionPromise; + + // Should wait for routes to load (up to finalTimeout) and get full route + expect(event.transaction).toBe('/deep/level2/level3/:id'); + expect(event.contexts?.trace?.data?.['sentry.source']).toBe('route'); + expect(event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); +}); + +test('idleTimeout: Captures all activity with increased timeout', async ({ page }) => { + const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction.includes('deep') + ); + }); + + // High idleTimeout (5000ms) ensures transaction captures all lazy loading activity + await page.goto('/?idleTimeout=5000'); + + const navigationLink = page.locator('id=navigation-to-deep'); + await expect(navigationLink).toBeVisible(); + await navigationLink.click(); + + const event = await transactionPromise; + + expect(event.transaction).toBe('/deep/level2/level3/:id'); + expect(event.contexts?.trace?.data?.['sentry.source']).toBe('route'); + expect(event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); + + // Transaction should wait for full idle timeout (5+ seconds) + const duration = event.timestamp! - event.start_timestamp; + expect(duration).toBeGreaterThan(5.0); + expect(duration).toBeLessThan(7.0); +}); + +test('idleTimeout: Finishes prematurely with low timeout', async ({ page }) => { + const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction.includes('deep') + ); + }); + + // Very low idleTimeout (50ms) and lazyRouteTimeout (100ms) + // Transaction finishes quickly, but still gets parameterized route name + await page.goto('/?idleTimeout=50&timeout=100'); + + const navigationLink = page.locator('id=navigation-to-deep'); + await expect(navigationLink).toBeVisible(); + await navigationLink.click(); + + const event = await transactionPromise; + + expect(event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); + expect(event.transaction).toBe('/deep/level2/level3/:id'); + expect(event.contexts?.trace?.data?.['sentry.source']).toBe('route'); + + // Transaction should finish quickly (< 200ms) + const duration = event.timestamp! - event.start_timestamp; + expect(duration).toBeLessThan(0.2); +}); + +test('idleTimeout: Pageload on deeply nested route', async ({ page }) => { + const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'pageload' && + transactionEvent.transaction.includes('deep') + ); + }); + + // Direct pageload to deeply nested route (not navigation) + await page.goto('/deep/level2/level3/12345'); + + const pageloadEvent = await pageloadPromise; + + expect(pageloadEvent.transaction).toBe('/deep/level2/level3/:id'); + expect(pageloadEvent.contexts?.trace?.data?.['sentry.source']).toBe('route'); + expect(pageloadEvent.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts index e5b9f35042ed..ce8137d7f686 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts @@ -588,3 +588,298 @@ test('Creates separate transactions for rapid consecutive navigations', async ({ expect(secondSpanId).not.toBe(thirdSpanId); expect(firstSpanId).not.toBe(thirdSpanId); }); + +test('Creates pageload transaction with parameterized route for delayed lazy route', async ({ page }) => { + const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'pageload' && + transactionEvent.transaction === '/delayed-lazy/:id' + ); + }); + + await page.goto('/delayed-lazy/123'); + + const pageloadEvent = await pageloadPromise; + + const delayedReady = page.locator('id=delayed-lazy-ready'); + await expect(delayedReady).toBeVisible(); + await expect(page.locator('id=delayed-lazy-id')).toHaveText('ID: 123'); + await expect(page.locator('id=delayed-lazy-path')).toHaveText('/delayed-lazy/123'); + + expect(pageloadEvent.transaction).toBe('/delayed-lazy/:id'); + expect(pageloadEvent.contexts?.trace?.op).toBe('pageload'); + expect(pageloadEvent.contexts?.trace?.data?.['sentry.source']).toBe('route'); +}); + +test('Creates navigation transaction with parameterized route for delayed lazy route', async ({ page }) => { + await page.goto('/'); + + const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/delayed-lazy/:id' + ); + }); + + const navigationLink = page.locator('id=navigation-to-delayed-lazy'); + await expect(navigationLink).toBeVisible(); + await navigationLink.click(); + + const navigationEvent = await navigationPromise; + + const delayedReady = page.locator('id=delayed-lazy-ready'); + await expect(delayedReady).toBeVisible(); + await expect(page.locator('id=delayed-lazy-id')).toHaveText('ID: 123'); + await expect(page.locator('id=delayed-lazy-path')).toHaveText('/delayed-lazy/123'); + + expect(navigationEvent.transaction).toBe('/delayed-lazy/:id'); + expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(navigationEvent.contexts?.trace?.data?.['sentry.source']).toBe('route'); +}); + +test('Creates navigation transaction when navigating with query parameters from home to route', async ({ page }) => { + await page.goto('/'); + + // Navigate from / to /delayed-lazy/123?source=homepage + // This should create a navigation transaction with the parameterized route name + const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/delayed-lazy/:id' + ); + }); + + const navigationLink = page.locator('id=navigation-to-delayed-lazy-with-query'); + await expect(navigationLink).toBeVisible(); + await navigationLink.click(); + + const navigationEvent = await navigationPromise; + + const delayedReady = page.locator('id=delayed-lazy-ready'); + await expect(delayedReady).toBeVisible(); + await expect(page.locator('id=delayed-lazy-id')).toHaveText('ID: 123'); + await expect(page.locator('id=delayed-lazy-path')).toHaveText('/delayed-lazy/123'); + await expect(page.locator('id=delayed-lazy-search')).toHaveText('?source=homepage'); + await expect(page.locator('id=delayed-lazy-source')).toHaveText('Source: homepage'); + + // Verify the navigation transaction has the correct parameterized route name + // Query parameters should NOT affect the transaction name (still /delayed-lazy/:id) + expect(navigationEvent.transaction).toBe('/delayed-lazy/:id'); + expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(navigationEvent.contexts?.trace?.data?.['sentry.source']).toBe('route'); + expect(navigationEvent.contexts?.trace?.status).toBe('ok'); +}); + +test('Creates separate navigation transaction when changing only query parameters on same route', async ({ page }) => { + await page.goto('/delayed-lazy/123'); + + // Wait for the page to fully load + const delayedReady = page.locator('id=delayed-lazy-ready'); + await expect(delayedReady).toBeVisible(); + + // Navigate from /delayed-lazy/123 to /delayed-lazy/123?view=detailed + // This is a query-only change on the same route + const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/delayed-lazy/:id' + ); + }); + + const queryLink = page.locator('id=link-to-query-view-detailed'); + await expect(queryLink).toBeVisible(); + await queryLink.click(); + + const navigationEvent = await navigationPromise; + + // Verify query param was updated + await expect(page.locator('id=delayed-lazy-search')).toHaveText('?view=detailed'); + await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: detailed'); + + // Query-only navigation should create a navigation transaction + expect(navigationEvent.transaction).toBe('/delayed-lazy/:id'); + expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(navigationEvent.contexts?.trace?.data?.['sentry.source']).toBe('route'); + expect(navigationEvent.contexts?.trace?.status).toBe('ok'); +}); + +test('Creates separate navigation transactions for multiple query parameter changes', async ({ page }) => { + await page.goto('/delayed-lazy/123'); + + const delayedReady = page.locator('id=delayed-lazy-ready'); + await expect(delayedReady).toBeVisible(); + + // First query change: /delayed-lazy/123 -> /delayed-lazy/123?view=detailed + const firstNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/delayed-lazy/:id' + ); + }); + + const firstQueryLink = page.locator('id=link-to-query-view-detailed'); + await expect(firstQueryLink).toBeVisible(); + await firstQueryLink.click(); + + const firstNavigationEvent = await firstNavigationPromise; + const firstTraceId = firstNavigationEvent.contexts?.trace?.trace_id; + + await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: detailed'); + + // Second query change: /delayed-lazy/123?view=detailed -> /delayed-lazy/123?view=list + const secondNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/delayed-lazy/:id' && + transactionEvent.contexts?.trace?.trace_id !== firstTraceId + ); + }); + + const secondQueryLink = page.locator('id=link-to-query-view-list'); + await expect(secondQueryLink).toBeVisible(); + await secondQueryLink.click(); + + const secondNavigationEvent = await secondNavigationPromise; + const secondTraceId = secondNavigationEvent.contexts?.trace?.trace_id; + + await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: list'); + + // Both navigations should have created separate transactions + expect(firstNavigationEvent.transaction).toBe('/delayed-lazy/:id'); + expect(firstNavigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(secondNavigationEvent.transaction).toBe('/delayed-lazy/:id'); + expect(secondNavigationEvent.contexts?.trace?.op).toBe('navigation'); + + // Trace IDs should be different (separate transactions) + expect(firstTraceId).toBeDefined(); + expect(secondTraceId).toBeDefined(); + expect(firstTraceId).not.toBe(secondTraceId); +}); + +test('Creates navigation transaction when changing only hash on same route', async ({ page }) => { + await page.goto('/delayed-lazy/123'); + + const delayedReady = page.locator('id=delayed-lazy-ready'); + await expect(delayedReady).toBeVisible(); + + // Navigate from /delayed-lazy/123 to /delayed-lazy/123#section1 + // This is a hash-only change on the same route + const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/delayed-lazy/:id' + ); + }); + + const hashLink = page.locator('id=link-to-hash-section1'); + await expect(hashLink).toBeVisible(); + await hashLink.click(); + + const navigationEvent = await navigationPromise; + + // Verify hash was updated + await expect(page.locator('id=delayed-lazy-hash')).toHaveText('#section1'); + + // Hash-only navigation should create a navigation transaction + expect(navigationEvent.transaction).toBe('/delayed-lazy/:id'); + expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(navigationEvent.contexts?.trace?.data?.['sentry.source']).toBe('route'); + expect(navigationEvent.contexts?.trace?.status).toBe('ok'); +}); + +test('Creates separate navigation transactions for multiple hash changes', async ({ page }) => { + await page.goto('/delayed-lazy/123'); + + const delayedReady = page.locator('id=delayed-lazy-ready'); + await expect(delayedReady).toBeVisible(); + + // First hash change: /delayed-lazy/123 -> /delayed-lazy/123#section1 + const firstNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/delayed-lazy/:id' + ); + }); + + const firstHashLink = page.locator('id=link-to-hash-section1'); + await expect(firstHashLink).toBeVisible(); + await firstHashLink.click(); + + const firstNavigationEvent = await firstNavigationPromise; + const firstTraceId = firstNavigationEvent.contexts?.trace?.trace_id; + + await expect(page.locator('id=delayed-lazy-hash')).toHaveText('#section1'); + + // Second hash change: /delayed-lazy/123#section1 -> /delayed-lazy/123#section2 + const secondNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/delayed-lazy/:id' && + transactionEvent.contexts?.trace?.trace_id !== firstTraceId + ); + }); + + const secondHashLink = page.locator('id=link-to-hash-section2'); + await expect(secondHashLink).toBeVisible(); + await secondHashLink.click(); + + const secondNavigationEvent = await secondNavigationPromise; + const secondTraceId = secondNavigationEvent.contexts?.trace?.trace_id; + + await expect(page.locator('id=delayed-lazy-hash')).toHaveText('#section2'); + + // Both navigations should have created separate transactions + expect(firstNavigationEvent.transaction).toBe('/delayed-lazy/:id'); + expect(firstNavigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(secondNavigationEvent.transaction).toBe('/delayed-lazy/:id'); + expect(secondNavigationEvent.contexts?.trace?.op).toBe('navigation'); + + // Trace IDs should be different (separate transactions) + expect(firstTraceId).toBeDefined(); + expect(secondTraceId).toBeDefined(); + expect(firstTraceId).not.toBe(secondTraceId); +}); + +test('Creates navigation transaction when changing both query and hash on same route', async ({ page }) => { + await page.goto('/delayed-lazy/123?view=list'); + + const delayedReady = page.locator('id=delayed-lazy-ready'); + await expect(delayedReady).toBeVisible(); + await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: list'); + + // Navigate from /delayed-lazy/123?view=list to /delayed-lazy/123?view=grid#results + // This changes both query and hash + const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/delayed-lazy/:id' + ); + }); + + const queryAndHashLink = page.locator('id=link-to-query-and-hash'); + await expect(queryAndHashLink).toBeVisible(); + await queryAndHashLink.click(); + + const navigationEvent = await navigationPromise; + + // Verify both query and hash were updated + await expect(page.locator('id=delayed-lazy-search')).toHaveText('?view=grid'); + await expect(page.locator('id=delayed-lazy-hash')).toHaveText('#results'); + await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: grid'); + + // Combined query + hash navigation should create a navigation transaction + expect(navigationEvent.transaction).toBe('/delayed-lazy/:id'); + expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(navigationEvent.contexts?.trace?.data?.['sentry.source']).toBe('route'); + expect(navigationEvent.contexts?.trace?.status).toBe('ok'); +}); diff --git a/packages/react/src/reactrouter-compat-utils/index.ts b/packages/react/src/reactrouter-compat-utils/index.ts index c2b56ec446fb..bb91ba8d3072 100644 --- a/packages/react/src/reactrouter-compat-utils/index.ts +++ b/packages/react/src/reactrouter-compat-utils/index.ts @@ -25,6 +25,7 @@ export { pathEndsWithWildcard, pathIsWildcardAndHasChildren, getNumberOfUrlSegments, + transactionNameHasWildcard, } from './utils'; // Lazy route exports diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index 235b207ed9a0..6e19b9021ba5 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -41,59 +41,118 @@ import type { UseRoutes, } from '../types'; import { checkRouteForAsyncHandler } from './lazy-routes'; -import { initializeRouterUtils, resolveRouteNameAndSource } from './utils'; +import { initializeRouterUtils, resolveRouteNameAndSource, transactionNameHasWildcard } from './utils'; let _useEffect: UseEffect; let _useLocation: UseLocation; let _useNavigationType: UseNavigationType; let _createRoutesFromChildren: CreateRoutesFromChildren; let _matchRoutes: MatchRoutes; + let _enableAsyncRouteHandlers: boolean = false; +let _lazyRouteTimeout = 3000; const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSetLevel 3 Deep Route
+Deeply nested route loaded!
+ID: {id}
+(); +// Prevents duplicate spans when router.subscribe fires multiple times +const activeNavigationSpans = new WeakMap< + Client, + { span: Span; routeName: string; pathname: string; locationKey: string; isPlaceholder?: boolean } +>(); + +// Exported for testing only +export const allRoutes = new Set (); + +// Tracks lazy route loads to wait before finalizing span names +const pendingLazyRouteLoads = new WeakMap>>(); + /** - * Tracks last navigation per client to prevent duplicate spans in cross-usage scenarios. - * Entry persists until the navigation span ends, allowing cross-usage detection during delayed wrapper execution. + * Schedules a callback using requestAnimationFrame when available (browser), + * or falls back to setTimeout for SSR environments (Node.js, createMemoryRouter tests). */ -const LAST_NAVIGATION_PER_CLIENT = new WeakMap (); +function scheduleCallback(callback: () => void): number { + if (WINDOW?.requestAnimationFrame) { + return WINDOW.requestAnimationFrame(callback); + } + return setTimeout(callback, 0) as unknown as number; +} -export function addResolvedRoutesToParent(resolvedRoutes: RouteObject[], parentRoute: RouteObject): void { - const existingChildren = parentRoute.children || []; +/** + * Cancels a scheduled callback, handling both RAF (browser) and timeout (SSR) IDs. + */ +function cancelScheduledCallback(id: number): void { + if (WINDOW?.cancelAnimationFrame) { + WINDOW.cancelAnimationFrame(id); + } else { + clearTimeout(id); + } +} - const newRoutes = resolvedRoutes.filter( - newRoute => - !existingChildren.some( - existing => - existing === newRoute || - (newRoute.path && existing.path === newRoute.path) || - (newRoute.id && existing.id === newRoute.id), - ), - ); +/** + * Computes location key for duplicate detection. Normalizes undefined/null to empty strings. + * Exported for testing. + */ +export function computeLocationKey(location: Location): string { + return `${location.pathname}${location.search || ''}${location.hash || ''}`; +} - if (newRoutes.length > 0) { - parentRoute.children = [...existingChildren, ...newRoutes]; - } +/** + * Checks if a route name is parameterized (contains route parameters like :id or wildcards like *) + * vs a raw URL path. + */ +function isParameterizedRoute(routeName: string): boolean { + return routeName.includes(':') || routeName.includes('*'); } /** - * Determines if a navigation should be handled based on router state. - * Only handles: - * - PUSH navigations (always) - * - POP navigations (only after initial pageload is complete) - * - When router state is 'idle' (not 'loading' or 'submitting') + * Determines if a navigation should be skipped as a duplicate, and if an existing span should be updated. + * Exported for testing. * - * During 'loading' or 'submitting', state.location may still have the old pathname, - * which would cause us to create a span for the wrong route. + * @returns An object with: + * - skip: boolean - Whether to skip creating a new span + * - shouldUpdate: boolean - Whether to update the existing span name (wildcard upgrade) */ -function shouldHandleNavigation( - state: { historyAction: string; navigation: { state: string } }, - isInitialPageloadComplete: boolean, -): boolean { - return ( - (state.historyAction === 'PUSH' || (state.historyAction === 'POP' && isInitialPageloadComplete)) && - state.navigation.state === 'idle' - ); +export function shouldSkipNavigation( + trackedNav: + | { span: Span; routeName: string; pathname: string; locationKey: string; isPlaceholder?: boolean } + | undefined, + locationKey: string, + proposedName: string, + spanHasEnded: boolean, +): { skip: boolean; shouldUpdate: boolean } { + if (!trackedNav) { + return { skip: false, shouldUpdate: false }; + } + + // Check if this is a duplicate navigation (same location) + // 1. If it's a placeholder, it's always a duplicate (we're waiting for the real one) + // 2. If it's a real span, it's a duplicate only if it hasn't ended yet + const isDuplicate = trackedNav.locationKey === locationKey && (trackedNav.isPlaceholder || !spanHasEnded); + + if (isDuplicate) { + // Check if we should update the span name with a better route + // Allow updates if: + // 1. Current has wildcard and new doesn't (wildcard → parameterized upgrade) + // 2. Current is raw path and new is parameterized (raw → parameterized upgrade) + // 3. New name is different and more specific (longer, indicating nested routes resolved) + const currentHasWildcard = !!trackedNav.routeName && transactionNameHasWildcard(trackedNav.routeName); + const proposedHasWildcard = transactionNameHasWildcard(proposedName); + const currentIsParameterized = !!trackedNav.routeName && isParameterizedRoute(trackedNav.routeName); + const proposedIsParameterized = isParameterizedRoute(proposedName); + + const isWildcardUpgrade = currentHasWildcard && !proposedHasWildcard; + const isRawToParameterized = !currentIsParameterized && proposedIsParameterized; + const isMoreSpecific = + proposedName !== trackedNav.routeName && + proposedName.length > (trackedNav.routeName?.length || 0) && + !proposedHasWildcard; + + const shouldUpdate = !!(trackedNav.routeName && (isWildcardUpgrade || isRawToParameterized || isMoreSpecific)); + + return { skip: true, shouldUpdate }; + } + + return { skip: false, shouldUpdate: false }; } export interface ReactRouterOptions { @@ -116,13 +175,58 @@ export interface ReactRouterOptions { * @default false */ enableAsyncRouteHandlers?: boolean; + + /** + * Maximum time (in milliseconds) to wait for lazy routes to load before finalizing span names. + * + * - Set to `0` to not wait at all (immediate finalization) + * - Set to `Infinity` to wait as long as possible (capped at `finalTimeout` to prevent indefinite hangs) + * - Negative values will fall back to the default + * + * Defaults to 3× the configured `idleTimeout` (default: 3000ms). + * + * @default idleTimeout * 3 + */ + lazyRouteTimeout?: number; } type V6CompatibleVersion = '6' | '7'; -// Keeping as a global variable for cross-usage in multiple functions -// only exported for testing purposes -export const allRoutes = new Set (); +export function addResolvedRoutesToParent(resolvedRoutes: RouteObject[], parentRoute: RouteObject): void { + const existingChildren = parentRoute.children || []; + + const newRoutes = resolvedRoutes.filter( + newRoute => + !existingChildren.some( + existing => + existing === newRoute || + (newRoute.path && existing.path === newRoute.path) || + (newRoute.id && existing.id === newRoute.id), + ), + ); + + if (newRoutes.length > 0) { + parentRoute.children = [...existingChildren, ...newRoutes]; + } +} + +/** Registers a pending lazy route load promise for a span. */ +function trackLazyRouteLoad(span: Span, promise: Promise ): void { + let promises = pendingLazyRouteLoads.get(span); + if (!promises) { + promises = new Set(); + pendingLazyRouteLoads.set(span, promises); + } + promises.add(promise); + + // Clean up when promise resolves/rejects + promise.finally(() => { + const currentPromises = pendingLazyRouteLoads.get(span); + if (currentPromises) { + currentPromises.delete(promise); + } + }); +} /** * Processes resolved routes by adding them to allRoutes and checking for nested async handlers. @@ -188,13 +292,14 @@ export function updateNavigationSpan( forceUpdate = false, matchRoutes: MatchRoutes, ): void { - // Check if this span has already been named to avoid multiple updates - // But allow updates if this is a forced update (e.g., when lazy routes are loaded) - const hasBeenNamed = - !forceUpdate && (activeRootSpan as { __sentry_navigation_name_set__?: boolean })?.__sentry_navigation_name_set__; + const spanJson = spanToJSON(activeRootSpan); + const currentName = spanJson.description; - if (!hasBeenNamed) { - // Get fresh branches for the current location with all loaded routes + const hasBeenNamed = (activeRootSpan as { __sentry_navigation_name_set__?: boolean })?.__sentry_navigation_name_set__; + const currentNameHasWildcard = currentName && transactionNameHasWildcard(currentName); + const shouldUpdate = !hasBeenNamed || forceUpdate || currentNameHasWildcard; + + if (shouldUpdate && !spanJson.timestamp) { const currentBranches = matchRoutes(allRoutes, location); const [name, source] = resolveRouteNameAndSource( location, @@ -204,22 +309,105 @@ export function updateNavigationSpan( '', ); - // Only update if we have a valid name and the span hasn't finished - const spanJson = spanToJSON(activeRootSpan); - if (name && !spanJson.timestamp) { + const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; + const isImprovement = + name && + (!currentName || // No current name - always set + (!hasBeenNamed && (currentSource !== 'route' || source === 'route')) || // Not finalized - allow unless downgrading route→url + (currentSource !== 'route' && source === 'route') || // URL → route upgrade + (currentSource === 'route' && source === 'route' && currentNameHasWildcard)); // Route → better route (only if current has wildcard) + if (isImprovement) { activeRootSpan.updateName(name); activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); - // Mark this span as having its name set to prevent future updates - addNonEnumerableProperty( - activeRootSpan as { __sentry_navigation_name_set__?: boolean }, - '__sentry_navigation_name_set__', - true, - ); + // Only mark as finalized for non-wildcard route names (allows URL→route upgrades). + if (!transactionNameHasWildcard(name) && source === 'route') { + addNonEnumerableProperty( + activeRootSpan as { __sentry_navigation_name_set__?: boolean }, + '__sentry_navigation_name_set__', + true, + ); + } } } } +function setupRouterSubscription( + router: Router, + routes: RouteObject[], + version: V6CompatibleVersion, + basename: string | undefined, + activeRootSpan: Span | undefined, +): void { + let isInitialPageloadComplete = false; + let hasSeenPageloadSpan = !!activeRootSpan && spanToJSON(activeRootSpan).op === 'pageload'; + let hasSeenPopAfterPageload = false; + let scheduledNavigationHandler: number | null = null; + let lastHandledPathname: string | null = null; + + router.subscribe((state: RouterState) => { + if (!isInitialPageloadComplete) { + const currentRootSpan = getActiveRootSpan(); + const isCurrentlyInPageload = currentRootSpan && spanToJSON(currentRootSpan).op === 'pageload'; + + if (isCurrentlyInPageload) { + hasSeenPageloadSpan = true; + } else if (hasSeenPageloadSpan) { + if (state.historyAction === 'POP' && !hasSeenPopAfterPageload) { + hasSeenPopAfterPageload = true; + } else { + isInitialPageloadComplete = true; + } + } + } + + const shouldHandleNavigation = + state.historyAction === 'PUSH' || (state.historyAction === 'POP' && isInitialPageloadComplete); + + if (shouldHandleNavigation) { + // Include search and hash to allow query/hash-only navigations + // Use computeLocationKey() to ensure undefined/null values are normalized to empty strings + const currentLocationKey = computeLocationKey(state.location); + const navigationHandler = (): void => { + // Prevent multiple calls for the same location within the same navigation cycle + if (lastHandledPathname === currentLocationKey) { + return; + } + lastHandledPathname = currentLocationKey; + scheduledNavigationHandler = null; + handleNavigation({ + location: state.location, + routes, + navigationType: state.historyAction, + version, + basename, + allRoutes: Array.from(allRoutes), + }); + }; + + if (state.navigation.state !== 'idle') { + // Navigation in progress - reset if location changed + if (lastHandledPathname !== currentLocationKey) { + lastHandledPathname = null; + } + // Cancel any previously scheduled handler to avoid duplicates + if (scheduledNavigationHandler !== null) { + cancelScheduledCallback(scheduledNavigationHandler); + } + scheduledNavigationHandler = scheduleCallback(navigationHandler); + } else { + // Navigation completed - cancel scheduled handler if any, then call immediately + if (scheduledNavigationHandler !== null) { + cancelScheduledCallback(scheduledNavigationHandler); + scheduledNavigationHandler = null; + } + navigationHandler(); + // Don't reset - next navigation cycle resets to prevent duplicates within same cycle. + } + } + }); +} + /** * Creates a wrapCreateBrowserRouter function that can be used with all React Router v6 compatible versions. */ @@ -242,30 +430,17 @@ export function createV6CompatibleWrapCreateBrowserRouter< return function (routes: RouteObject[], opts?: Record & { basename?: string }): TRouter { addRoutesToAllRoutes(routes); - // Check for async handlers that might contain sub-route declarations (only if enabled) if (_enableAsyncRouteHandlers) { for (const route of routes) { checkRouteForAsyncHandler(route, processResolvedRoutes); } } - // Wrap patchRoutesOnNavigation to detect when lazy routes are loaded const wrappedOpts = wrapPatchRoutesOnNavigation(opts); - const router = createRouterFunction(routes, wrappedOpts); const basename = opts?.basename; - const activeRootSpan = getActiveRootSpan(); - // Track whether we've completed the initial pageload to properly distinguish - // between POPs that occur during pageload vs. legitimate back/forward navigation. - let isInitialPageloadComplete = false; - let hasSeenPageloadSpan = !!activeRootSpan && spanToJSON(activeRootSpan).op === 'pageload'; - let hasSeenPopAfterPageload = false; - - // The initial load ends when `createBrowserRouter` is called. - // This is the earliest convenient time to update the transaction name. - // Callbacks to `router.subscribe` are not called for the initial load. if (router.state.historyAction === 'POP' && activeRootSpan) { updatePageloadTransaction({ activeRootSpan, @@ -276,38 +451,7 @@ export function createV6CompatibleWrapCreateBrowserRouter< }); } - router.subscribe((state: RouterState) => { - // Track pageload completion to distinguish POPs during pageload from legitimate back/forward navigation - if (!isInitialPageloadComplete) { - const currentRootSpan = getActiveRootSpan(); - const isCurrentlyInPageload = currentRootSpan && spanToJSON(currentRootSpan).op === 'pageload'; - - if (isCurrentlyInPageload) { - hasSeenPageloadSpan = true; - } else if (hasSeenPageloadSpan) { - // Pageload span was active but is now gone - pageload has completed - if (state.historyAction === 'POP' && !hasSeenPopAfterPageload) { - // Pageload ended: ignore the first POP after pageload - hasSeenPopAfterPageload = true; - } else { - // Pageload ended: either non-POP action or subsequent POP - isInitialPageloadComplete = true; - } - } - // If we haven't seen a pageload span yet, keep waiting (don't mark as complete) - } - - if (shouldHandleNavigation(state, isInitialPageloadComplete)) { - handleNavigation({ - location: state.location, - routes, - navigationType: state.historyAction, - version, - basename, - allRoutes: Array.from(allRoutes), - }); - } - }); + setupRouterSubscription(router, routes, version, basename, activeRootSpan); return router; }; @@ -342,14 +486,12 @@ export function createV6CompatibleWrapCreateMemoryRouter< ): TRouter { addRoutesToAllRoutes(routes); - // Check for async handlers that might contain sub-route declarations (only if enabled) if (_enableAsyncRouteHandlers) { for (const route of routes) { checkRouteForAsyncHandler(route, processResolvedRoutes); } } - // Wrap patchRoutesOnNavigation to detect when lazy routes are loaded const wrappedOpts = wrapPatchRoutesOnNavigation(opts, true); const router = createRouterFunction(routes, wrappedOpts); @@ -387,44 +529,7 @@ export function createV6CompatibleWrapCreateMemoryRouter< }); } - // Track whether we've completed the initial pageload to properly distinguish - // between POPs that occur during pageload vs. legitimate back/forward navigation. - let isInitialPageloadComplete = false; - let hasSeenPageloadSpan = !!memoryActiveRootSpan && spanToJSON(memoryActiveRootSpan).op === 'pageload'; - let hasSeenPopAfterPageload = false; - - router.subscribe((state: RouterState) => { - // Track pageload completion to distinguish POPs during pageload from legitimate back/forward navigation - if (!isInitialPageloadComplete) { - const currentRootSpan = getActiveRootSpan(); - const isCurrentlyInPageload = currentRootSpan && spanToJSON(currentRootSpan).op === 'pageload'; - - if (isCurrentlyInPageload) { - hasSeenPageloadSpan = true; - } else if (hasSeenPageloadSpan) { - // Pageload span was active but is now gone - pageload has completed - if (state.historyAction === 'POP' && !hasSeenPopAfterPageload) { - // Pageload ended: ignore the first POP after pageload - hasSeenPopAfterPageload = true; - } else { - // Pageload ended: either non-POP action or subsequent POP - isInitialPageloadComplete = true; - } - } - // If we haven't seen a pageload span yet, keep waiting (don't mark as complete) - } - - if (shouldHandleNavigation(state, isInitialPageloadComplete)) { - handleNavigation({ - location: state.location, - routes, - navigationType: state.historyAction, - version, - basename, - allRoutes: Array.from(allRoutes), - }); - } - }); + setupRouterSubscription(router, routes, version, basename, memoryActiveRootSpan); return router; }; @@ -449,6 +554,7 @@ export function createReactRouterV6CompatibleTracingIntegration( enableAsyncRouteHandlers = false, instrumentPageLoad = true, instrumentNavigation = true, + lazyRouteTimeout, } = options; return { @@ -456,6 +562,36 @@ export function createReactRouterV6CompatibleTracingIntegration( setup(client) { integration.setup(client); + const finalTimeout = options.finalTimeout ?? 30000; + const defaultMaxWait = (options.idleTimeout ?? 1000) * 3; + const configuredMaxWait = lazyRouteTimeout ?? defaultMaxWait; + + // Cap Infinity at finalTimeout to prevent indefinite hangs + if (configuredMaxWait === Infinity) { + _lazyRouteTimeout = finalTimeout; + DEBUG_BUILD && + debug.log( + '[React Router] lazyRouteTimeout set to Infinity, capping at finalTimeout:', + finalTimeout, + 'ms to prevent indefinite hangs', + ); + } else if (Number.isNaN(configuredMaxWait)) { + DEBUG_BUILD && + debug.warn('[React Router] lazyRouteTimeout must be a number, falling back to default:', defaultMaxWait); + _lazyRouteTimeout = defaultMaxWait; + } else if (configuredMaxWait < 0) { + DEBUG_BUILD && + debug.warn( + '[React Router] lazyRouteTimeout must be non-negative or Infinity, got:', + configuredMaxWait, + 'falling back to:', + defaultMaxWait, + ); + _lazyRouteTimeout = defaultMaxWait; + } else { + _lazyRouteTimeout = configuredMaxWait; + } + _useEffect = useEffect; _useLocation = useLocation; _useNavigationType = useNavigationType; @@ -530,6 +666,9 @@ export function createV6CompatibleWrapUseRoutes(origUseRoutes: UseRoutes, versio }); isMountRenderPass.current = false; } else { + // Note: Component-based routes don't support lazy route tracking via lazyRouteTimeout + // because React.lazy() loads happen at the component level, not the router level. + // Use createBrowserRouter with patchRoutesOnNavigation for lazy route tracking. handleNavigation({ location: normalizedLocation, routes, @@ -564,7 +703,8 @@ function wrapPatchRoutesOnNavigation( // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access const targetPath = (args as any)?.path; - // For browser router, wrap the patch function to update span during patching + const activeRootSpan = getActiveRootSpan(); + if (!isMemoryRouter) { // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access const originalPatch = (args as any)?.patch; @@ -572,13 +712,13 @@ function wrapPatchRoutesOnNavigation( // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access (args as any).patch = (routeId: string, children: RouteObject[]) => { addRoutesToAllRoutes(children); - const activeRootSpan = getActiveRootSpan(); - if (activeRootSpan && (spanToJSON(activeRootSpan) as { op?: string }).op === 'navigation') { + const currentActiveRootSpan = getActiveRootSpan(); + if (currentActiveRootSpan && (spanToJSON(currentActiveRootSpan) as { op?: string }).op === 'navigation') { updateNavigationSpan( - activeRootSpan, + currentActiveRootSpan, { pathname: targetPath, search: '', hash: '', state: null, key: 'default' }, Array.from(allRoutes), - true, // forceUpdate = true since we're loading lazy routes + true, _matchRoutes, ); } @@ -587,102 +727,37 @@ function wrapPatchRoutesOnNavigation( } } - const result = await originalPatchRoutes(args); - - // Update navigation span after routes are patched - const activeRootSpan = getActiveRootSpan(); - if (activeRootSpan && (spanToJSON(activeRootSpan) as { op?: string }).op === 'navigation') { - // Determine pathname based on router type - let pathname: string | undefined; - if (isMemoryRouter) { - // For memory routers, only use targetPath - pathname = targetPath; - } else { - // For browser routers, use targetPath or fall back to window.location - pathname = targetPath || WINDOW.location?.pathname; + const lazyLoadPromise = (async () => { + const result = await originalPatchRoutes(args); + + const currentActiveRootSpan = getActiveRootSpan(); + if (currentActiveRootSpan && (spanToJSON(currentActiveRootSpan) as { op?: string }).op === 'navigation') { + const pathname = isMemoryRouter ? targetPath : targetPath || WINDOW.location?.pathname; + + if (pathname) { + updateNavigationSpan( + currentActiveRootSpan, + { pathname, search: '', hash: '', state: null, key: 'default' }, + Array.from(allRoutes), + false, + _matchRoutes, + ); + } } - if (pathname) { - updateNavigationSpan( - activeRootSpan, - { pathname, search: '', hash: '', state: null, key: 'default' }, - Array.from(allRoutes), - false, // forceUpdate = false since this is after lazy routes are loaded - _matchRoutes, - ); - } + return result; + })(); + + if (activeRootSpan) { + trackLazyRouteLoad(activeRootSpan, lazyLoadPromise); } - return result; + return lazyLoadPromise; }, }; } -function getNavigationKey(location: Location): string { - return `${location.pathname}${location.search}${location.hash}`; -} - -function tryUpdateSpanName( - activeSpan: Span, - currentSpanName: string | undefined, - newName: string, - newSource: string, -): void { - // Check if the new name contains React Router parameter syntax (/:param/) - const isReactRouterParam = /\/:[a-zA-Z0-9_]+/.test(newName); - const isNewNameParameterized = newName !== currentSpanName && isReactRouterParam; - if (isNewNameParameterized) { - activeSpan.updateName(newName); - activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, newSource as 'route' | 'url' | 'custom'); - } -} - -function isDuplicateNavigation(client: Client, navigationKey: string): boolean { - const lastKey = LAST_NAVIGATION_PER_CLIENT.get(client); - return lastKey === navigationKey; -} - -function createNavigationSpan(opts: { - client: Client; - name: string; - source: string; - version: string; - location: Location; - routes: RouteObject[]; - basename?: string; - allRoutes?: RouteObject[]; - navigationKey: string; -}): Span | undefined { - const { client, name, source, version, location, routes, basename, allRoutes, navigationKey } = opts; - - const navigationSpan = startBrowserTracingNavigationSpan(client, { - name, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source as 'route' | 'url' | 'custom', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`, - }, - }); - - if (navigationSpan) { - LAST_NAVIGATION_PER_CLIENT.set(client, navigationKey); - patchNavigationSpanEnd(navigationSpan, location, routes, basename, allRoutes); - - const unsubscribe = client.on('spanEnd', endedSpan => { - if (endedSpan === navigationSpan) { - // Clear key only if it's still our key (handles overlapping navigations) - const lastKey = LAST_NAVIGATION_PER_CLIENT.get(client); - if (lastKey === navigationKey) { - LAST_NAVIGATION_PER_CLIENT.delete(client); - } - unsubscribe(); // Prevent memory leak - } - }); - } - - return navigationSpan; -} - +// eslint-disable-next-line complexity export function handleNavigation(opts: { location: Location; routes: RouteObject[]; @@ -714,33 +789,84 @@ export function handleNavigation(opts: { basename, ); - const currentNavigationKey = getNavigationKey(location); - const isNavDuplicate = isDuplicateNavigation(client, currentNavigationKey); + const locationKey = computeLocationKey(location); + const trackedNav = activeNavigationSpans.get(client); + + // Determine if this navigation should be skipped as a duplicate + const trackedSpanHasEnded = + trackedNav && !trackedNav.isPlaceholder ? !!spanToJSON(trackedNav.span).timestamp : false; + const { skip, shouldUpdate } = shouldSkipNavigation(trackedNav, locationKey, name, trackedSpanHasEnded); + + if (skip) { + if (shouldUpdate && trackedNav) { + const oldName = trackedNav.routeName; + + if (trackedNav.isPlaceholder) { + // Update placeholder's route name - the real span will be created with this name + trackedNav.routeName = name; + DEBUG_BUILD && + debug.log( + `[Tracing] Updated placeholder navigation name from "${oldName}" to "${name}" (will apply to real span)`, + ); + } else { + // Update existing real span from wildcard to parameterized route name + trackedNav.span.updateName(name); + trackedNav.span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source as 'route' | 'url' | 'custom'); + addNonEnumerableProperty( + trackedNav.span as { __sentry_navigation_name_set__?: boolean }, + '__sentry_navigation_name_set__', + true, + ); + trackedNav.routeName = name; + DEBUG_BUILD && debug.log(`[Tracing] Updated navigation span name from "${oldName}" to "${name}"`); + } + } else { + DEBUG_BUILD && debug.log(`[Tracing] Skipping duplicate navigation for location: ${locationKey}`); + } + return; + } - if (isNavDuplicate) { - // Cross-usage duplicate - update existing span name if better - const activeSpan = getActiveSpan(); - const spanJson = activeSpan && spanToJSON(activeSpan); - const isAlreadyInNavigationSpan = spanJson?.op === 'navigation'; + // Create new navigation span (first navigation or legitimate new navigation) + // Reserve the spot in the map first to prevent race conditions + // Mark as placeholder to prevent concurrent handleNavigation calls from creating duplicates + const placeholderSpan = { end: () => {} } as unknown as Span; + const placeholderEntry = { + span: placeholderSpan, + routeName: name, + pathname: location.pathname, + locationKey, + isPlaceholder: true as const, + }; + activeNavigationSpans.set(client, placeholderEntry); + + let navigationSpan: Span | undefined; + try { + navigationSpan = startBrowserTracingNavigationSpan(client, { + name: placeholderEntry.routeName, // Use placeholder's routeName in case it was updated + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`, + }, + }); + } catch (e) { + // If span creation fails, remove the placeholder so we don't block future navigations + activeNavigationSpans.delete(client); + throw e; + } - if (isAlreadyInNavigationSpan && activeSpan) { - tryUpdateSpanName(activeSpan, spanJson?.description, name, source); - } - } else { - // Not a cross-usage duplicate - create new span - // This handles: different routes, same route with different params (/user/2 → /user/3) - // startBrowserTracingNavigationSpan will end any active navigation span - createNavigationSpan({ - client, - name, - source, - version, - location, - routes, - basename, - allRoutes, - navigationKey: currentNavigationKey, + if (navigationSpan) { + // Update the map with the real span (isPlaceholder omitted, defaults to false) + activeNavigationSpans.set(client, { + span: navigationSpan, + routeName: placeholderEntry.routeName, // Use the (potentially updated) placeholder routeName + pathname: location.pathname, + locationKey, }); + patchSpanEnd(navigationSpan, location, routes, basename, allRoutes, 'navigation'); + } else { + // If no span was created, remove the placeholder + activeNavigationSpans.delete(client); } } } @@ -809,11 +935,93 @@ function updatePageloadTransaction({ activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); // Patch span.end() to ensure we update the name one last time before the span is sent - patchPageloadSpanEnd(activeRootSpan, location, routes, basename, allRoutes); + patchSpanEnd(activeRootSpan, location, routes, basename, allRoutes, 'pageload'); } } } +/** + * Determines if a span name should be updated during wildcard route resolution. + * + * Update conditions (in priority order): + * 1. No current name + allowNoCurrentName: true → always update (pageload spans) + * 2. Current name has wildcard + new is route without wildcard → upgrade (e.g., "/users/*" → "/users/:id") + * 3. Current source is not 'route' + new source is 'route' → upgrade (e.g., URL → parameterized route) + * + * @param currentName - The current span name (may be undefined) + * @param currentSource - The current span source ('route', 'url', or undefined) + * @param newName - The proposed new span name + * @param newSource - The proposed new span source + * @param allowNoCurrentName - If true, allow updates when there's no current name (for pageload spans) + * @returns true if the span name should be updated + */ +function shouldUpdateWildcardSpanName( + currentName: string | undefined, + currentSource: string | undefined, + newName: string, + newSource: string, + allowNoCurrentName = false, +): boolean { + if (!newName) { + return false; + } + + if (!currentName && allowNoCurrentName) { + return true; + } + + const hasWildcard = currentName && transactionNameHasWildcard(currentName); + + if (hasWildcard && newSource === 'route' && !transactionNameHasWildcard(newName)) { + return true; + } + + if (currentSource !== 'route' && newSource === 'route') { + return true; + } + + return false; +} + +function tryUpdateSpanNameBeforeEnd( + span: Span, + spanJson: ReturnType , + currentName: string | undefined, + location: Location, + routes: RouteObject[], + basename: string | undefined, + spanType: 'pageload' | 'navigation', + allRoutes: Set , +): void { + try { + const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; + + if (currentSource === 'route' && currentName && !transactionNameHasWildcard(currentName)) { + return; + } + + const currentAllRoutes = Array.from(allRoutes); + const routesToUse = currentAllRoutes.length > 0 ? currentAllRoutes : routes; + const branches = _matchRoutes(routesToUse, location, basename) as unknown as RouteMatch[]; + + if (!branches) { + return; + } + + const [name, source] = resolveRouteNameAndSource(location, routesToUse, routesToUse, branches, basename); + + const isImprovement = shouldUpdateWildcardSpanName(currentName, currentSource, name, source, true); + const spanNotEnded = spanType === 'pageload' || !spanJson.timestamp; + + if (isImprovement && spanNotEnded) { + span.updateName(name); + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); + } + } catch (error) { + DEBUG_BUILD && debug.warn(`Error updating span details before ending: ${error}`); + } +} + /** * Patches the span.end() method to update the transaction name one last time before the span is sent. * This handles cases where the span is cancelled early (e.g., document.hidden) before lazy routes have finished loading. @@ -833,71 +1041,93 @@ function patchSpanEnd( return; } + // Use the passed route context, or fall back to global Set + const allRoutesSet = _allRoutes ? new Set(_allRoutes) : allRoutes; + const originalEnd = span.end.bind(span); + let endCalled = false; span.end = function patchedEnd(...args) { - try { - // Only update if the span source is not already 'route' (i.e., it hasn't been parameterized yet) - const spanJson = spanToJSON(span); - const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; - if (currentSource !== 'route') { - // Last chance to update the transaction name with the latest route info - // Use the live global allRoutes Set to include any lazy routes loaded after patching - const currentAllRoutes = Array.from(allRoutes); - const branches = _matchRoutes( - currentAllRoutes.length > 0 ? currentAllRoutes : routes, - location, - basename, - ) as unknown as RouteMatch[]; + if (endCalled) { + return; + } + endCalled = true; + + // Capture timestamp immediately to avoid delay from async operations + // If no timestamp was provided, capture the current time now + const endTimestamp = args.length > 0 ? args[0] : Date.now() / 1000; + + const spanJson = spanToJSON(span); + const currentName = spanJson.description; + const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; + + // Helper to clean up activeNavigationSpans after span ends + const cleanupNavigationSpan = (): void => { + const client = getClient(); + if (client && spanType === 'navigation') { + const trackedNav = activeNavigationSpans.get(client); + if (trackedNav && trackedNav.span === span) { + activeNavigationSpans.delete(client); + } + } + }; + + const pendingPromises = pendingLazyRouteLoads.get(span); + // Wait for lazy routes if: + // 1. There are pending promises AND + // 2. Current name exists AND + // 3. Either the name has a wildcard OR the source is not 'route' (URL-based names) + const shouldWaitForLazyRoutes = + pendingPromises && + pendingPromises.size > 0 && + currentName && + (transactionNameHasWildcard(currentName) || currentSource !== 'route'); + + if (shouldWaitForLazyRoutes) { + if (_lazyRouteTimeout === 0) { + tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutesSet); + cleanupNavigationSpan(); + originalEnd(endTimestamp); + return; + } - if (branches) { - const [name, source] = resolveRouteNameAndSource( + const allSettled = Promise.allSettled(pendingPromises).then(() => {}); + const waitPromise = + _lazyRouteTimeout === Infinity + ? allSettled + : Promise.race([allSettled, new Promise (r => setTimeout(r, _lazyRouteTimeout))]); + + waitPromise + .then(() => { + const updatedSpanJson = spanToJSON(span); + tryUpdateSpanNameBeforeEnd( + span, + updatedSpanJson, + updatedSpanJson.description, location, - currentAllRoutes.length > 0 ? currentAllRoutes : routes, - currentAllRoutes.length > 0 ? currentAllRoutes : routes, - branches, + routes, basename, + spanType, + allRoutesSet, ); - - // Only update if we have a valid name - if (name && (spanType === 'pageload' || !spanJson.timestamp)) { - span.updateName(name); - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); - } - } - } - } catch (error) { - // Silently catch errors to ensure span.end() is always called - DEBUG_BUILD && debug.warn(`Error updating span details before ending: ${error}`); + cleanupNavigationSpan(); + originalEnd(endTimestamp); + }) + .catch(() => { + cleanupNavigationSpan(); + originalEnd(endTimestamp); + }); + return; } - return originalEnd(...args); + tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutesSet); + cleanupNavigationSpan(); + originalEnd(endTimestamp); }; - // Mark this span as having its end() method patched to prevent duplicate patching addNonEnumerableProperty(span as unknown as Record , patchedPropertyName, true); } -function patchPageloadSpanEnd( - span: Span, - location: Location, - routes: RouteObject[], - basename: string | undefined, - _allRoutes: RouteObject[] | undefined, -): void { - patchSpanEnd(span, location, routes, basename, _allRoutes, 'pageload'); -} - -function patchNavigationSpanEnd( - span: Span, - location: Location, - routes: RouteObject[], - basename: string | undefined, - _allRoutes: RouteObject[] | undefined, -): void { - patchSpanEnd(span, location, routes, basename, _allRoutes, 'navigation'); -} - // eslint-disable-next-line @typescript-eslint/no-explicit-any export function createV6CompatibleWithSentryReactRouterRouting , R extends React.FC
>( Routes: R, @@ -933,11 +1163,13 @@ export function createV6CompatibleWithSentryReactRouterRouting
{ return { ...(actual as any), startBrowserTracingNavigationSpan: vi.fn(), + startBrowserTracingPageLoadSpan: vi.fn(), browserTracingIntegration: vi.fn(() => ({ setup: vi.fn(), afterAllSetup: vi.fn(), @@ -49,6 +56,9 @@ vi.mock('../../src/reactrouter-compat-utils/utils', () => ({ getGlobalLocation: vi.fn(() => ({ pathname: '/test', search: '', hash: '' })), getGlobalPathname: vi.fn(() => '/test'), routeIsDescendant: vi.fn(() => false), + transactionNameHasWildcard: vi.fn((name: string) => { + return name.includes('/*') || name === '*' || name.endsWith('*'); + }), })); vi.mock('../../src/reactrouter-compat-utils/lazy-routes', () => ({ @@ -370,3 +380,932 @@ describe('addRoutesToAllRoutes', () => { expect(firstCount).toBe(secondCount); }); }); + +describe('updateNavigationSpan with wildcard detection', () => { + const sampleLocation: Location = { + pathname: '/test', + search: '', + hash: '', + state: null, + key: 'default', + }; + + const sampleRoutes: RouteObject[] = [ + { path: '/', element:
Home}, + { path: '/about', element:About}, + ]; + + const mockMatchRoutes = vi.fn(() => []); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call updateName when provided with valid routes', () => { + const testSpan = { ...mockSpan }; + updateNavigationSpan(testSpan, sampleLocation, sampleRoutes, false, mockMatchRoutes); + + expect(mockUpdateName).toHaveBeenCalledWith('Test Route'); + expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route'); + }); + + it('should handle forced updates', () => { + const testSpan = { ...mockSpan, __sentry_navigation_name_set__: true }; + updateNavigationSpan(testSpan, sampleLocation, sampleRoutes, true, mockMatchRoutes); + + // Should update even though already named because forceUpdate=true + expect(mockUpdateName).toHaveBeenCalledWith('Test Route'); + }); +}); + +describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should upgrade from URL source to route source (regression fix)', async () => { + // Setup: Current span has URL source and non-parameterized name + vi.mocked(spanToJSON).mockReturnValue({ + op: 'navigation', + description: '/users/123', + data: { 'sentry.source': 'url' }, + } as any); + + // Target: Resolves to route source with parameterized name + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/:id', 'route']); + + const mockUpdateName = vi.fn(); + const mockSetAttribute = vi.fn(); + const testSpan = { + updateName: mockUpdateName, + setAttribute: mockSetAttribute, + end: vi.fn(), + } as unknown as Span; + + // Simulate patchSpanEnd calling tryUpdateSpanNameBeforeEnd + // by updating the span name during a navigation + updateNavigationSpan( + testSpan, + { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' }, + [{ path: '/users/:id', element: }], + false, + vi.fn(() => [{ route: { path: '/users/:id' } }]), + ); + + // Should upgrade from URL to route source + expect(mockUpdateName).toHaveBeenCalledWith('/users/:id'); + expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route'); + }); + + it('should not downgrade from route source to URL source', async () => { + // Setup: Current span has route source with parameterized name (no wildcard) + vi.mocked(spanToJSON).mockReturnValue({ + op: 'navigation', + description: '/users/:id', + data: { 'sentry.source': 'route' }, + } as any); + + // Target: Would resolve to URL source (downgrade attempt) + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/456', 'url']); + + const mockUpdateName = vi.fn(); + const mockSetAttribute = vi.fn(); + const testSpan = { + updateName: mockUpdateName, + setAttribute: mockSetAttribute, + end: vi.fn(), + __sentry_navigation_name_set__: true, // Mark as already named + } as unknown as Span; + + updateNavigationSpan( + testSpan, + { pathname: '/users/456', search: '', hash: '', state: null, key: 'test' }, + [{ path: '/users/:id', element: }], + false, + vi.fn(() => [{ route: { path: '/users/:id' } }]), + ); + + // Should not update because span is already named + // The early return in tryUpdateSpanNameBeforeEnd protects against downgrades + // This test verifies that route->url downgrades are blocked + expect(mockUpdateName).not.toHaveBeenCalled(); + expect(mockSetAttribute).not.toHaveBeenCalled(); + }); + + it('should upgrade wildcard names to specific routes', async () => { + // Setup: Current span has route source with wildcard + vi.mocked(spanToJSON).mockReturnValue({ + op: 'navigation', + description: '/users/*', + data: { 'sentry.source': 'route' }, + } as any); + + // Mock wildcard detection: current name has wildcard, new name doesn't + vi.mocked(transactionNameHasWildcard).mockImplementation((name: string) => { + return name === '/users/*'; // Only the current name has wildcard + }); + + // Target: Resolves to specific parameterized route + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/:id', 'route']); + + const mockUpdateName = vi.fn(); + const mockSetAttribute = vi.fn(); + const testSpan = { + updateName: mockUpdateName, + setAttribute: mockSetAttribute, + end: vi.fn(), + } as unknown as Span; + + updateNavigationSpan( + testSpan, + { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' }, + [{ path: '/users/:id', element: }], + false, + vi.fn(() => [{ route: { path: '/users/:id' } }]), + ); + + // Should upgrade from wildcard to specific + expect(mockUpdateName).toHaveBeenCalledWith('/users/:id'); + expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route'); + }); + + it('should not downgrade from wildcard route to URL', async () => { + // Setup: Current span has route source with wildcard + vi.mocked(spanToJSON).mockReturnValue({ + op: 'navigation', + description: '/users/*', + data: { 'sentry.source': 'route' }, + } as any); + + // Mock wildcard detection: current name has wildcard, new name doesn't + vi.mocked(transactionNameHasWildcard).mockImplementation((name: string) => { + return name === '/users/*'; // Only the current wildcard name returns true + }); + + // Target: After timeout, resolves to URL (lazy route didn't finish loading) + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/123', 'url']); + + const mockUpdateName = vi.fn(); + const mockSetAttribute = vi.fn(); + const testSpan = { + updateName: mockUpdateName, + setAttribute: mockSetAttribute, + end: vi.fn(), + __sentry_navigation_name_set__: true, // Mark span as already named/finalized + } as unknown as Span; + + updateNavigationSpan( + testSpan, + { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' }, + [{ path: '/users/*', element: }], + false, + vi.fn(() => [{ route: { path: '/users/*' } }]), + ); + + // Should not update - keep wildcard route instead of downgrading to URL + // Wildcard routes are better than URLs for aggregation in performance monitoring + expect(mockUpdateName).not.toHaveBeenCalled(); + expect(mockSetAttribute).not.toHaveBeenCalled(); + }); + + it('should set name when no current name exists', async () => { + // Setup: Current span has no name (undefined) + vi.mocked(spanToJSON).mockReturnValue({ + op: 'navigation', + description: undefined, + } as any); + + // Target: Resolves to route + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/:id', 'route']); + + const mockUpdateName = vi.fn(); + const mockSetAttribute = vi.fn(); + const testSpan = { + updateName: mockUpdateName, + setAttribute: mockSetAttribute, + end: vi.fn(), + } as unknown as Span; + + updateNavigationSpan( + testSpan, + { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' }, + [{ path: '/users/:id', element: }], + false, + vi.fn(() => [{ route: { path: '/users/:id' } }]), + ); + + // Should set initial name + expect(mockUpdateName).toHaveBeenCalledWith('/users/:id'); + expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route'); + }); + + it('should not update when same source and no improvement', async () => { + // Setup: Current span has URL source + vi.mocked(spanToJSON).mockReturnValue({ + op: 'navigation', + description: '/users/123', + data: { 'sentry.source': 'url' }, + } as any); + + // Target: Resolves to same URL source (no improvement) + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/123', 'url']); + + const mockUpdateName = vi.fn(); + const mockSetAttribute = vi.fn(); + const testSpan = { + updateName: mockUpdateName, + setAttribute: mockSetAttribute, + end: vi.fn(), + } as unknown as Span; + + updateNavigationSpan( + testSpan, + { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' }, + [{ path: '/users/:id', element: }], + false, + vi.fn(() => [{ route: { path: '/users/:id' } }]), + ); + + // Note: updateNavigationSpan always updates if not already named + // This test validates that the isImprovement logic works correctly in tryUpdateSpanNameBeforeEnd + // which is called during span.end() patching + expect(mockUpdateName).toHaveBeenCalled(); // Initial set is allowed + }); + + describe('computeLocationKey (pure function)', () => { + it('should include pathname, search, and hash in location key', () => { + const location: Location = { + pathname: '/search', + search: '?q=foo', + hash: '#results', + state: null, + key: 'test', + }; + + const result = computeLocationKey(location); + + expect(result).toBe('/search?q=foo#results'); + }); + + it('should differentiate locations with same pathname but different query', () => { + const loc1: Location = { pathname: '/search', search: '?q=foo', hash: '', state: null, key: 'k1' }; + const loc2: Location = { pathname: '/search', search: '?q=bar', hash: '', state: null, key: 'k2' }; + + const key1 = computeLocationKey(loc1); + const key2 = computeLocationKey(loc2); + + // Verifies that search params are included in the location key + expect(key1).not.toBe(key2); + expect(key1).toBe('/search?q=foo'); + expect(key2).toBe('/search?q=bar'); + }); + + it('should differentiate locations with same pathname but different hash', () => { + const loc1: Location = { pathname: '/page', search: '', hash: '#section1', state: null, key: 'k1' }; + const loc2: Location = { pathname: '/page', search: '', hash: '#section2', state: null, key: 'k2' }; + + const key1 = computeLocationKey(loc1); + const key2 = computeLocationKey(loc2); + + // Verifies that hash values are included in the location key + expect(key1).not.toBe(key2); + expect(key1).toBe('/page#section1'); + expect(key2).toBe('/page#section2'); + }); + + it('should produce same key for identical locations', () => { + const loc1: Location = { pathname: '/users', search: '?id=123', hash: '#profile', state: null, key: 'k1' }; + const loc2: Location = { pathname: '/users', search: '?id=123', hash: '#profile', state: null, key: 'k2' }; + + expect(computeLocationKey(loc1)).toBe(computeLocationKey(loc2)); + }); + + it('should normalize undefined/null search and hash to empty strings (partial location objects)', () => { + // Whenreceives a string, React Router creates a partial location + // with search: undefined and hash: undefined. We must normalize these to empty strings + // to match the keys from full location objects (which have search: '' and hash: ''). + // This prevents duplicate navigation spans when using prop (common in modal routes). + const partialLocation: Location = { + pathname: '/users', + search: undefined as unknown as string, + hash: undefined as unknown as string, + state: null, + key: 'test1', + }; + + const fullLocation: Location = { + pathname: '/users', + search: '', + hash: '', + state: null, + key: 'test2', + }; + + const partialKey = computeLocationKey(partialLocation); + const fullKey = computeLocationKey(fullLocation); + + // Verifies that undefined values are normalized to empty strings, preventing + // '/usersundefinedundefined' !== '/users' mismatches + expect(partialKey).toBe('/users'); + expect(fullKey).toBe('/users'); + expect(partialKey).toBe(fullKey); + }); + + it('should normalize null search and hash to empty strings', () => { + const locationWithNulls: Location = { + pathname: '/products', + search: null as unknown as string, + hash: null as unknown as string, + state: null, + key: 'test3', + }; + + const locationWithEmptyStrings: Location = { + pathname: '/products', + search: '', + hash: '', + state: null, + key: 'test4', + }; + + expect(computeLocationKey(locationWithNulls)).toBe('/products'); + expect(computeLocationKey(locationWithEmptyStrings)).toBe('/products'); + expect(computeLocationKey(locationWithNulls)).toBe(computeLocationKey(locationWithEmptyStrings)); + }); + }); + + describe('shouldSkipNavigation (pure function - duplicate detection logic)', () => { + const mockSpan: Span = { updateName: vi.fn(), setAttribute: vi.fn(), end: vi.fn() } as unknown as Span; + + it('should not skip when no tracked navigation exists', () => { + const result = shouldSkipNavigation(undefined, '/users', '/users/:id', false); + + expect(result).toEqual({ skip: false, shouldUpdate: false }); + }); + + it('should skip placeholder navigations for same locationKey', () => { + const trackedNav = { + span: mockSpan, + routeName: '/search', + pathname: '/search', + locationKey: '/search?q=foo', + isPlaceholder: true, + }; + + const result = shouldSkipNavigation(trackedNav, '/search?q=foo', '/search', false); + + // Verifies that placeholder navigations for the same locationKey are skipped + expect(result.skip).toBe(true); + expect(result.shouldUpdate).toBe(false); + }); + + it('should NOT skip placeholder navigations for different locationKey (query change)', () => { + const trackedNav = { + span: mockSpan, + routeName: '/search', + pathname: '/search', + locationKey: '/search?q=foo', + isPlaceholder: true, + }; + + const result = shouldSkipNavigation(trackedNav, '/search?q=bar', '/search', false); + + // Verifies that different locationKeys allow new navigation even with same pathname + expect(result.skip).toBe(false); + expect(result.shouldUpdate).toBe(false); + }); + + it('should skip real span navigations for same locationKey when span has not ended', () => { + const trackedNav = { + span: mockSpan, + routeName: '/users/:id', + pathname: '/users/123', + locationKey: '/users/123?tab=profile', + isPlaceholder: false, + }; + + const result = shouldSkipNavigation(trackedNav, '/users/123?tab=profile', '/users/:id', false); + + // Verifies that duplicate navigations are blocked when span hasn't ended + expect(result.skip).toBe(true); + }); + + it('should NOT skip real span navigations for different locationKey (query change)', () => { + const trackedNav = { + span: mockSpan, + routeName: '/users/:id', + pathname: '/users/123', + locationKey: '/users/123?tab=profile', + isPlaceholder: false, + }; + + const result = shouldSkipNavigation(trackedNav, '/users/123?tab=settings', '/users/:id', false); + + // Verifies that different locationKeys allow new navigation even with same pathname + expect(result.skip).toBe(false); + }); + + it('should NOT skip when tracked span has ended', () => { + const trackedNav = { + span: mockSpan, + routeName: '/users/:id', + pathname: '/users/123', + locationKey: '/users/123', + isPlaceholder: false, + }; + + const result = shouldSkipNavigation(trackedNav, '/users/123', '/users/:id', true); + + // Allow new navigation when previous span has ended + expect(result.skip).toBe(false); + }); + + it('should set shouldUpdate=true for wildcard to parameterized upgrade', () => { + const trackedNav = { + span: mockSpan, + routeName: '/users/*', + pathname: '/users/123', + locationKey: '/users/123', + isPlaceholder: false, + }; + + const result = shouldSkipNavigation(trackedNav, '/users/123', '/users/:id', false); + + // Verifies that wildcard names are upgraded to parameterized routes + expect(result.skip).toBe(true); + expect(result.shouldUpdate).toBe(true); + }); + + it('should NOT set shouldUpdate=true when both names are wildcards', () => { + const trackedNav = { + span: mockSpan, + routeName: '/users/*', + pathname: '/users/123', + locationKey: '/users/123', + isPlaceholder: false, + }; + + const result = shouldSkipNavigation(trackedNav, '/users/123', '/users/*', false); + + expect(result.skip).toBe(true); + expect(result.shouldUpdate).toBe(false); + }); + }); + + describe('handleNavigation integration (verifies wiring to pure functions)', () => { + // Verifies that handleNavigation correctly uses computeLocationKey and shouldSkipNavigation + + let mockNavigationSpan: Span; + + beforeEach(async () => { + // Reset all mocks + vi.clearAllMocks(); + + // Import fresh modules to reset internal state + const coreModule = await import('@sentry/core'); + const browserModule = await import('@sentry/browser'); + const instrumentationModule = await import('../../src/reactrouter-compat-utils/instrumentation'); + + // Create a mock span with end() that captures callback + mockNavigationSpan = { + updateName: vi.fn(), + setAttribute: vi.fn(), + end: vi.fn(), + } as unknown as Span; + + // Mock getClient to return a client that's registered for instrumentation + const mockClient = { + addIntegration: vi.fn(), + emit: vi.fn(), + on: vi.fn(), + getOptions: vi.fn(() => ({})), + } as unknown as Client; + vi.mocked(coreModule.getClient).mockReturnValue(mockClient); + + // Mock startBrowserTracingPageLoadSpan to avoid pageload span creation during setup + vi.mocked(browserModule.startBrowserTracingPageLoadSpan).mockReturnValue(undefined); + + // Register client for instrumentation by adding it to the internal set + const integration = instrumentationModule.createReactRouterV6CompatibleTracingIntegration({ + useEffect: vi.fn(), + useLocation: vi.fn(), + useNavigationType: vi.fn(), + createRoutesFromChildren: vi.fn(), + matchRoutes: vi.fn(), + }); + integration.afterAllSetup(mockClient); + + // Mock startBrowserTracingNavigationSpan to return our mock span + vi.mocked(browserModule.startBrowserTracingNavigationSpan).mockReturnValue(mockNavigationSpan); + + // Mock spanToJSON to return different values for different calls + vi.mocked(coreModule.spanToJSON).mockReturnValue({ op: 'navigation' } as any); + + // Mock getActiveRootSpan to return undefined (no pageload span) + vi.mocked(coreModule.getActiveSpan).mockReturnValue(undefined); + }); + + it('creates navigation span and uses computeLocationKey for tracking', async () => { + const { handleNavigation } = await import('../../src/reactrouter-compat-utils/instrumentation'); + const { startBrowserTracingNavigationSpan } = await import('@sentry/browser'); + const { resolveRouteNameAndSource } = await import('../../src/reactrouter-compat-utils/utils'); + + // Mock to return a specific route name + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/search', 'route']); + + const location: Location = { + pathname: '/search', + search: '?q=foo', + hash: '#results', + state: null, + key: 'test1', + }; + + const matches = [ + { + pathname: '/search', + pathnameBase: '/search', + route: { path: '/search', element: }, + params: {}, + }, + ]; + + handleNavigation({ + location, + routes: [{ path: '/search', element: }], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + // Verifies that handleNavigation calls startBrowserTracingNavigationSpan + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledWith( + expect.objectContaining({ emit: expect.any(Function) }), // client + expect.objectContaining({ + name: '/search', + attributes: expect.objectContaining({ + 'sentry.op': 'navigation', + 'sentry.source': 'route', + }), + }), + ); + }); + + it('blocks duplicate navigation for exact same locationKey (pathname+query+hash)', async () => { + const { handleNavigation } = await import('../../src/reactrouter-compat-utils/instrumentation'); + const { startBrowserTracingNavigationSpan } = await import('@sentry/browser'); + const { spanToJSON } = await import('@sentry/core'); + + const location: Location = { + pathname: '/search', + search: '?q=foo', + hash: '#results', + state: null, + key: 'test1', + }; + + const matches = [ + { + pathname: '/search', + pathnameBase: '/search', + route: { path: '/search', element: }, + params: {}, + }, + ]; + + // First navigation - should create span + handleNavigation({ + location, + routes: [{ path: '/search', element: }], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + // Mock spanToJSON to indicate span hasn't ended yet + vi.mocked(spanToJSON).mockReturnValue({ op: 'navigation' } as any); + + // Second navigation - exact same location, should be blocked + handleNavigation({ + location: { ...location, key: 'test2' }, // Different key, same location + routes: [{ path: '/search', element: }], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + // Verifies that duplicate detection uses locationKey (not just pathname) + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); // Only first call + }); + + it('allows navigation for same pathname but different query string', async () => { + const { handleNavigation } = await import('../../src/reactrouter-compat-utils/instrumentation'); + const { startBrowserTracingNavigationSpan } = await import('@sentry/browser'); + const { spanToJSON } = await import('@sentry/core'); + + const location1: Location = { + pathname: '/search', + search: '?q=foo', + hash: '', + state: null, + key: 'test1', + }; + + const matches = [ + { + pathname: '/search', + pathnameBase: '/search', + route: { path: '/search', element: }, + params: {}, + }, + ]; + + // First navigation + handleNavigation({ + location: location1, + routes: [{ path: '/search', element: }], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + // Mock spanToJSON to indicate span hasn't ended yet + vi.mocked(spanToJSON).mockReturnValue({ op: 'navigation' } as any); + + // Second navigation - same pathname, different query + const location2: Location = { + pathname: '/search', + search: '?q=bar', + hash: '', + state: null, + key: 'test2', + }; + + handleNavigation({ + location: location2, + routes: [{ path: '/search', element: }], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + // Verifies that query params are included in locationKey for duplicate detection + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); // Both calls should create spans + }); + + it('allows navigation for same pathname but different hash', async () => { + const { handleNavigation } = await import('../../src/reactrouter-compat-utils/instrumentation'); + const { startBrowserTracingNavigationSpan } = await import('@sentry/browser'); + const { spanToJSON } = await import('@sentry/core'); + + const location1: Location = { + pathname: '/page', + search: '', + hash: '#section1', + state: null, + key: 'test1', + }; + + const matches = [ + { + pathname: '/page', + pathnameBase: '/page', + route: { path: '/page', element: }, + params: {}, + }, + ]; + + // First navigation + handleNavigation({ + location: location1, + routes: [{ path: '/page', element: }], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + // Mock spanToJSON to indicate span hasn't ended yet + vi.mocked(spanToJSON).mockReturnValue({ op: 'navigation' } as any); + + // Second navigation - same pathname, different hash + const location2: Location = { + pathname: '/page', + search: '', + hash: '#section2', + state: null, + key: 'test2', + }; + + handleNavigation({ + location: location2, + routes: [{ path: '/page', element: }], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + // Verifies that hash values are included in locationKey for duplicate detection + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); // Both calls should create spans + }); + + it('updates wildcard span when better parameterized name becomes available', async () => { + const { handleNavigation } = await import('../../src/reactrouter-compat-utils/instrumentation'); + const { startBrowserTracingNavigationSpan } = await import('@sentry/browser'); + const { spanToJSON } = await import('@sentry/core'); + const { transactionNameHasWildcard, resolveRouteNameAndSource } = await import( + '../../src/reactrouter-compat-utils/utils' + ); + + const location: Location = { + pathname: '/users/123', + search: '', + hash: '', + state: null, + key: 'test1', + }; + + const matches = [ + { + pathname: '/users/123', + pathnameBase: '/users', + route: { path: '/users/*', element: }, + params: { '*': '123' }, + }, + ]; + + // First navigation - resolves to wildcard name + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/*', 'route']); + // Mock transactionNameHasWildcard to return true for wildcards, false for parameterized + vi.mocked(transactionNameHasWildcard).mockImplementation((name: string) => { + return name.includes('/*') || name === '*' || name.endsWith('*'); + }); + + handleNavigation({ + location, + routes: [{ path: '/users/*', element: }], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + const firstSpan = mockNavigationSpan; + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + + // Mock spanToJSON to indicate span hasn't ended yet and has wildcard name + vi.mocked(spanToJSON).mockReturnValue({ + op: 'navigation', + description: '/users/*', + data: { 'sentry.source': 'route' }, + } as any); + + // Second navigation - same location but better parameterized name available + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/:id', 'route']); + + handleNavigation({ + location: { ...location, key: 'test2' }, + routes: [{ path: '/users/:id', element: }], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + // Verifies that wildcard span names are upgraded when parameterized routes become available + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(vi.mocked(firstSpan.updateName)).toHaveBeenCalledWith('/users/:id'); + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); // No new span created + }); + + it('prevents duplicate spans when prop is a string (partial location)', async () => { + // This test verifies the fix for the bug where creates + // a partial location object with search: undefined and hash: undefined, which + // would result in a different locationKey ('/usersundefinedundefined' vs '/users') + // causing duplicate navigation spans. + const { handleNavigation } = await import('../../src/reactrouter-compat-utils/instrumentation'); + const { startBrowserTracingNavigationSpan } = await import('@sentry/browser'); + const { spanToJSON } = await import('@sentry/core'); + const { resolveRouteNameAndSource } = await import('../../src/reactrouter-compat-utils/utils'); + + // Mock resolveRouteNameAndSource to return consistent route name + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users', 'route']); + + const matches = [ + { + pathname: '/users', + pathnameBase: '/users', + route: { path: '/users', element: }, + params: {}, + }, + ]; + + // First call: Partial location (from ) + // React Router creates location with undefined search and hash + const partialLocation: Location = { + pathname: '/users', + search: undefined as unknown as string, + hash: undefined as unknown as string, + state: null, + key: 'test1', + }; + + handleNavigation({ + location: partialLocation, + routes: [{ path: '/users', element: }], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + + // Mock spanToJSON to indicate span hasn't ended yet + vi.mocked(spanToJSON).mockReturnValue({ op: 'navigation' } as any); + + // Second call: Full location (from router.state) + // React Router provides location with empty string search and hash + const fullLocation: Location = { + pathname: '/users', + search: '', + hash: '', + state: null, + key: 'test2', + }; + + handleNavigation({ + location: fullLocation, + routes: [{ path: '/users', element: }], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + // Verifies that undefined values are normalized, preventing duplicate spans + // (without normalization, '/usersundefinedundefined' != '/users' would create 2 spans) + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + }); + }); + + describe('SSR-safe RAF fallback (scheduleCallback/cancelScheduledCallback)', () => { + // These tests verify that the RAF fallback works correctly in SSR environments + + it('uses requestAnimationFrame when available', () => { + // Save original RAF + const originalRAF = window.requestAnimationFrame; + const rafSpy = vi.fn((cb: () => void) => { + cb(); + return 123; + }); + window.requestAnimationFrame = rafSpy; + + try { + // Import module to trigger RAF usage + const scheduleCallback = (callback: () => void): number => { + if (window?.requestAnimationFrame) { + return window.requestAnimationFrame(callback); + } + return setTimeout(callback, 0) as unknown as number; + }; + + const mockCallback = vi.fn(); + scheduleCallback(mockCallback); + + // Verifies that requestAnimationFrame is used when available + expect(rafSpy).toHaveBeenCalled(); + expect(mockCallback).toHaveBeenCalled(); + } finally { + window.requestAnimationFrame = originalRAF; + } + }); + + it('falls back to setTimeout when requestAnimationFrame is unavailable (SSR)', () => { + // Simulate SSR by removing RAF + const originalRAF = window.requestAnimationFrame; + const originalCAF = window.cancelAnimationFrame; + // @ts-expect-error - Simulating SSR environment + delete window.requestAnimationFrame; + // @ts-expect-error - Simulating SSR environment + delete window.cancelAnimationFrame; + + try { + const timeoutSpy = vi.spyOn(global, 'setTimeout'); + + // Import module to trigger setTimeout fallback + const scheduleCallback = (callback: () => void): number => { + if (window?.requestAnimationFrame) { + return window.requestAnimationFrame(callback); + } + return setTimeout(callback, 0) as unknown as number; + }; + + const mockCallback = vi.fn(); + scheduleCallback(mockCallback); + + // Verifies that setTimeout is used when requestAnimationFrame is unavailable + expect(timeoutSpy).toHaveBeenCalledWith(mockCallback, 0); + } finally { + window.requestAnimationFrame = originalRAF; + window.cancelAnimationFrame = originalCAF; + } + }); + }); +}); diff --git a/packages/react/test/reactrouter-compat-utils/utils.test.ts b/packages/react/test/reactrouter-compat-utils/utils.test.ts index 9ff48e7450bc..438b026104bd 100644 --- a/packages/react/test/reactrouter-compat-utils/utils.test.ts +++ b/packages/react/test/reactrouter-compat-utils/utils.test.ts @@ -9,6 +9,7 @@ import { prefixWithSlash, rebuildRoutePathFromAllRoutes, resolveRouteNameAndSource, + transactionNameHasWildcard, } from '../../src/reactrouter-compat-utils'; import type { Location, MatchRoutes, RouteMatch, RouteObject } from '../../src/types'; @@ -629,4 +630,38 @@ describe('reactrouter-compat-utils/utils', () => { expect(result).toEqual(['/unknown', 'url']); }); }); + + describe('transactionNameHasWildcard', () => { + it('should detect wildcard at the end of path', () => { + expect(transactionNameHasWildcard('/lazy/*')).toBe(true); + expect(transactionNameHasWildcard('/users/:id/*')).toBe(true); + expect(transactionNameHasWildcard('/products/:category/*')).toBe(true); + }); + + it('should detect standalone wildcard', () => { + expect(transactionNameHasWildcard('*')).toBe(true); + }); + + it('should detect wildcard in the middle of path', () => { + expect(transactionNameHasWildcard('/lazy/*/nested')).toBe(true); + expect(transactionNameHasWildcard('/a/*/b/*/c')).toBe(true); + }); + + it('should not detect wildcards in parameterized routes', () => { + expect(transactionNameHasWildcard('/users/:id')).toBe(false); + expect(transactionNameHasWildcard('/products/:category/:id')).toBe(false); + expect(transactionNameHasWildcard('/items/:itemId/details')).toBe(false); + }); + + it('should not detect wildcards in static routes', () => { + expect(transactionNameHasWildcard('/')).toBe(false); + expect(transactionNameHasWildcard('/about')).toBe(false); + expect(transactionNameHasWildcard('/users/profile')).toBe(false); + }); + + it('should handle edge cases', () => { + expect(transactionNameHasWildcard('')).toBe(false); + expect(transactionNameHasWildcard('/path/to/asterisk')).toBe(false); // 'asterisk' contains 'isk' but not '*' + }); + }); }); From 2ee464fc1a00c60e9438e6a5c27dc09e976e5e9c Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Mon, 24 Nov 2025 10:14:07 +0100 Subject: [PATCH 24/32] chore: Add external contributor to CHANGELOG.md (#18297) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #18281 Co-authored-by: nicohrubec <29484629+nicohrubec@users.noreply.github.com> --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 479b72fc2f08..ac6b755cbb24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +Work in this release was contributed by @bignoncedric. Thank you for your contribution! + - feat(deps): Bump OpenTelemetry ([#18239](https://github.com/getsentry/sentry-javascript/pull/18239)) - Bump @opentelemetry/context-async-hooks from ^2.1.0 to ^2.2.0 - Bump @opentelemetry/core from ^2.1.0 to ^2.2.0 From b8127fbec3ab3412931eb643911384fa77f5dd9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81d=C3=A1m=20Kov=C3=A1cs?= <43071496+adam-kov@users.noreply.github.com> Date: Mon, 24 Nov 2025 11:01:17 +0100 Subject: [PATCH 25/32] doc(sveltekit): Update documentation link for SvelteKit guide (#18298) Readme incorrectly pointed to NextJS docs --- packages/sveltekit/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sveltekit/README.md b/packages/sveltekit/README.md index a21adc43b836..a7a51e695255 100644 --- a/packages/sveltekit/README.md +++ b/packages/sveltekit/README.md @@ -25,7 +25,7 @@ functionality related to SvelteKit. ## Installation To get started installing the SDK, use the Sentry Next.js Wizard by running the following command in your terminal or -read the [Getting Started Docs](https://docs.sentry.io/platforms/javascript/guides/nextjs/): +read the [Getting Started Docs](https://docs.sentry.io/platforms/javascript/guides/sveltekit/): ```sh npx @sentry/wizard@latest -i sveltekit From 3d48cc66730723653893ebab7d1b3edab6e9ff3c Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Mon, 24 Nov 2025 11:11:51 +0100 Subject: [PATCH 26/32] chore: Add external contributor to CHANGELOG.md (#18300) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #18298 Co-authored-by: Lms24 <8420481+Lms24@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac6b755cbb24..9bee97ac9189 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -Work in this release was contributed by @bignoncedric. Thank you for your contribution! +Work in this release was contributed by @bignoncedric and @adam-kov. Thank you for your contributions! - feat(deps): Bump OpenTelemetry ([#18239](https://github.com/getsentry/sentry-javascript/pull/18239)) - Bump @opentelemetry/context-async-hooks from ^2.1.0 to ^2.2.0 From 15256034ee8150a5b7dcb97d23eca1a5486f0cae Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 24 Nov 2025 14:59:08 +0100 Subject: [PATCH 27/32] feat(browserprofiling): Add `manual` mode and deprecate old profiling (#18189) Adds the `manual` mode for profiling and browser integration tests. - adds deprecation note for old option - adds some JSDoc comments to public-facing API to make the difference between Node and UI profiling better visible. Closes https://github.com/getsentry/sentry-javascript/issues/17279 --- .../suites/profiling/manualMode/subject.js | 76 ++++ .../suites/profiling/manualMode/test.ts | 93 +++++ .../suites/profiling/test-utils.ts | 2 +- packages/browser/src/client.ts | 1 - packages/browser/src/exports.ts | 1 + packages/browser/src/profiling/UIProfiler.ts | 150 ++++--- packages/browser/src/profiling/index.ts | 55 +++ packages/browser/src/profiling/integration.ts | 23 +- packages/browser/src/profiling/utils.ts | 16 +- .../browser/test/profiling/UIProfiler.test.ts | 367 ++++++++++++++---- packages/core/src/client.ts | 28 ++ packages/core/src/profiling.ts | 5 + .../core/src/types-hoist/browseroptions.ts | 3 +- 13 files changed, 681 insertions(+), 139 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/profiling/manualMode/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/profiling/manualMode/test.ts create mode 100644 packages/browser/src/profiling/index.ts diff --git a/dev-packages/browser-integration-tests/suites/profiling/manualMode/subject.js b/dev-packages/browser-integration-tests/suites/profiling/manualMode/subject.js new file mode 100644 index 000000000000..906f14d06693 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/manualMode/subject.js @@ -0,0 +1,76 @@ +import * as Sentry from '@sentry/browser'; +import { browserProfilingIntegration } from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [browserProfilingIntegration()], + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'manual', +}); + +function largeSum(amount = 1000000) { + let sum = 0; + for (let i = 0; i < amount; i++) { + sum += Math.sqrt(i) * Math.sin(i); + } +} + +function fibonacci(n) { + if (n <= 1) { + return n; + } + return fibonacci(n - 1) + fibonacci(n - 2); +} + +function fibonacci1(n) { + if (n <= 1) { + return n; + } + return fibonacci1(n - 1) + fibonacci1(n - 2); +} + +function fibonacci2(n) { + if (n <= 1) { + return n; + } + return fibonacci1(n - 1) + fibonacci1(n - 2); +} + +function notProfiledFib(n) { + if (n <= 1) { + return n; + } + return fibonacci1(n - 1) + fibonacci1(n - 2); +} + +// Adding setTimeout to ensure we cross the sampling interval to avoid flakes + +Sentry.uiProfiler.startProfiler(); + +fibonacci(40); +await new Promise(resolve => setTimeout(resolve, 25)); + +largeSum(); +await new Promise(resolve => setTimeout(resolve, 25)); + +Sentry.uiProfiler.stopProfiler(); + +// --- + +notProfiledFib(40); +await new Promise(resolve => setTimeout(resolve, 25)); + +// --- + +Sentry.uiProfiler.startProfiler(); + +fibonacci2(40); +await new Promise(resolve => setTimeout(resolve, 25)); + +Sentry.uiProfiler.stopProfiler(); + +const client = Sentry.getClient(); +await client?.flush(8000); diff --git a/dev-packages/browser-integration-tests/suites/profiling/manualMode/test.ts b/dev-packages/browser-integration-tests/suites/profiling/manualMode/test.ts new file mode 100644 index 000000000000..2e4358563aa2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/manualMode/test.ts @@ -0,0 +1,93 @@ +import { expect } from '@playwright/test'; +import type { ProfileChunkEnvelope } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { + countEnvelopes, + getMultipleSentryEnvelopeRequests, + properFullEnvelopeRequestParser, + shouldSkipTracingTest, +} from '../../../utils/helpers'; +import { validateProfile, validateProfilePayloadMetadata } from '../test-utils'; + +sentryTest( + 'does not send profile envelope when document-policy is not set', + async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + // Profiling only works when tracing is enabled + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + // Assert that no profile_chunk envelope is sent without policy header + const chunkCount = await countEnvelopes(page, { url, envelopeType: 'profile_chunk', timeout: 1500 }); + expect(chunkCount).toBe(0); + }, +); + +sentryTest('sends profile_chunk envelopes in manual mode', async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + // Profiling only works when tracing is enabled + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname, responseHeaders: { 'Document-Policy': 'js-profiling' } }); + + // In manual mode we start and stop once -> expect exactly one chunk + const profileChunkEnvelopes = await getMultipleSentryEnvelopeRequests ( + page, + 2, + { url, envelopeType: 'profile_chunk', timeout: 8000 }, + properFullEnvelopeRequestParser, + ); + + expect(profileChunkEnvelopes.length).toBe(2); + + // Validate the first chunk thoroughly + const profileChunkEnvelopeItem = profileChunkEnvelopes[0][1][0]; + const envelopeItemHeader = profileChunkEnvelopeItem[0]; + const envelopeItemPayload1 = profileChunkEnvelopeItem[1]; + + expect(envelopeItemHeader).toHaveProperty('type', 'profile_chunk'); + expect(envelopeItemPayload1.profile).toBeDefined(); + + const profilerId1 = envelopeItemPayload1.profiler_id; + + validateProfilePayloadMetadata(envelopeItemPayload1); + + validateProfile(envelopeItemPayload1.profile, { + expectedFunctionNames: ['startJSSelfProfile', 'fibonacci', 'largeSum'], + minSampleDurationMs: 20, + isChunkFormat: true, + }); + + // only contains fibonacci + const functionNames1 = envelopeItemPayload1.profile.frames.map(frame => frame.function).filter(name => name !== ''); + expect(functionNames1).toEqual(expect.not.arrayContaining(['fibonacci1', 'fibonacci2', 'fibonacci3'])); + + // === PROFILE CHUNK 2 === + + const profileChunkEnvelopeItem2 = profileChunkEnvelopes[1][1][0]; + const envelopeItemHeader2 = profileChunkEnvelopeItem2[0]; + const envelopeItemPayload2 = profileChunkEnvelopeItem2[1]; + + expect(envelopeItemHeader2).toHaveProperty('type', 'profile_chunk'); + expect(envelopeItemPayload2.profile).toBeDefined(); + + expect(envelopeItemPayload2.profiler_id).toBe(profilerId1); // same profiler id for the whole session + + validateProfilePayloadMetadata(envelopeItemPayload2); + + validateProfile(envelopeItemPayload2.profile, { + expectedFunctionNames: [ + 'startJSSelfProfile', + 'fibonacci1', // called by fibonacci2 + 'fibonacci2', + ], + isChunkFormat: true, + }); + + // does not contain notProfiledFib (called during unprofiled part) + const functionNames2 = envelopeItemPayload2.profile.frames.map(frame => frame.function).filter(name => name !== ''); + expect(functionNames2).toEqual(expect.not.arrayContaining(['notProfiledFib'])); +}); diff --git a/dev-packages/browser-integration-tests/suites/profiling/test-utils.ts b/dev-packages/browser-integration-tests/suites/profiling/test-utils.ts index e150be2d56bc..39e6d2ca20b7 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/test-utils.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/test-utils.ts @@ -90,7 +90,7 @@ export function validateProfile( } } - // Frames + // FRAMES expect(profile.frames.length).toBeGreaterThan(0); for (const frame of profile.frames) { expect(frame).toHaveProperty('function'); diff --git a/packages/browser/src/client.ts b/packages/browser/src/client.ts index ea55174f340c..65fcdf24734a 100644 --- a/packages/browser/src/client.ts +++ b/packages/browser/src/client.ts @@ -130,7 +130,6 @@ export class BrowserClient extends Client { // Flush logs and metrics when page becomes hidden (e.g., tab switch, navigation) // todo(v11): Remove the experimental flag - // eslint-disable-next-line deprecation/deprecation if (WINDOW.document && (sendClientReports || enableLogs || enableMetrics)) { WINDOW.document.addEventListener('visibilitychange', () => { if (WINDOW.document.visibilityState === 'hidden') { diff --git a/packages/browser/src/exports.ts b/packages/browser/src/exports.ts index 50223e4b9fd9..1b46687194da 100644 --- a/packages/browser/src/exports.ts +++ b/packages/browser/src/exports.ts @@ -78,6 +78,7 @@ export { export { WINDOW } from './helpers'; export { BrowserClient } from './client'; export { makeFetchTransport } from './transports/fetch'; +export { uiProfiler } from './profiling'; export { defaultStackParser, defaultStackLineParsers, diff --git a/packages/browser/src/profiling/UIProfiler.ts b/packages/browser/src/profiling/UIProfiler.ts index 731684996d62..fb059b836986 100644 --- a/packages/browser/src/profiling/UIProfiler.ts +++ b/packages/browser/src/profiling/UIProfiler.ts @@ -1,4 +1,4 @@ -import type { Client, ProfileChunk, Span } from '@sentry/core'; +import type { Client, ContinuousProfiler, ProfileChunk, Span } from '@sentry/core'; import { type ProfileChunkEnvelope, createEnvelope, @@ -9,67 +9,122 @@ import { getSdkMetadataForEnvelopeHeader, uuid4, } from '@sentry/core'; +import type { BrowserOptions } from '../client'; import { DEBUG_BUILD } from './../debug-build'; import type { JSSelfProfiler } from './jsSelfProfiling'; -import { createProfileChunkPayload, startJSSelfProfile, validateProfileChunk } from './utils'; +import { createProfileChunkPayload, shouldProfileSession, startJSSelfProfile, validateProfileChunk } from './utils'; const CHUNK_INTERVAL_MS = 60_000; // 1 minute // Maximum length for trace lifecycle profiling per root span (e.g. if spanEnd never fires) -const MAX_ROOT_SPAN_PROFILE_MS = 300_000; // 5 minutes +const MAX_ROOT_SPAN_PROFILE_MS = 300_000; // 5 minutes max per root span in trace mode /** - * Browser trace-lifecycle profiler (UI Profiling / Profiling V2): - * - Starts when the first sampled root span starts - * - Stops when the last sampled root span ends - * - While running, periodically stops and restarts the JS self-profiling API to collect chunks + * UIProfiler (Profiling V2): + * Supports two lifecycle modes: + * - 'manual': controlled explicitly via start()/stop() + * - 'trace': automatically runs while there are active sampled root spans * * Profiles are emitted as standalone `profile_chunk` envelopes either when: * - there are no more sampled root spans, or * - the 60s chunk timer elapses while profiling is running. */ -export class UIProfiler { +export class UIProfiler implements ContinuousProfiler { private _client: Client | undefined; private _profiler: JSSelfProfiler | undefined; private _chunkTimer: ReturnType | undefined; - // For keeping track of active root spans + + // Manual + Trace + private _profilerId: string | undefined; // one per Profiler session + private _isRunning: boolean; // current profiler instance active flag + private _sessionSampled: boolean; // sampling decision for entire session + private _lifecycleMode: 'manual' | 'trace' | undefined; + + // Trace-only private _activeRootSpanIds: Set ; private _rootSpanTimeouts: Map >; - // ID for Profiler session - private _profilerId: string | undefined; - private _isRunning: boolean; - private _sessionSampled: boolean; public constructor() { this._client = undefined; this._profiler = undefined; this._chunkTimer = undefined; - this._activeRootSpanIds = new Set (); - this._rootSpanTimeouts = new Map >(); + this._profilerId = undefined; this._isRunning = false; this._sessionSampled = false; + this._lifecycleMode = undefined; + + this._activeRootSpanIds = new Set(); + this._rootSpanTimeouts = new Map(); } /** - * Initialize the profiler with client and session sampling decision computed by the integration. + * Initialize the profiler with client, session sampling and lifecycle mode. */ - public initialize(client: Client, sessionSampled: boolean): void { - // One Profiler ID per profiling session (user session) - this._profilerId = uuid4(); + public initialize(client: Client): void { + const lifecycleMode = (client.getOptions() as BrowserOptions).profileLifecycle; + const sessionSampled = shouldProfileSession(client.getOptions()); + + DEBUG_BUILD && debug.log(`[Profiling] Initializing profiler (lifecycle='${lifecycleMode}').`); - DEBUG_BUILD && debug.log("[Profiling] Initializing profiler (lifecycle='trace')."); + if (!sessionSampled) { + DEBUG_BUILD && debug.log('[Profiling] Session not sampled. Skipping lifecycle profiler initialization.'); + } + // One Profiler ID per profiling session (user session) + this._profilerId = uuid4(); this._client = client; this._sessionSampled = sessionSampled; + this._lifecycleMode = lifecycleMode; - this._setupTraceLifecycleListeners(client); + if (lifecycleMode === 'trace') { + this._setupTraceLifecycleListeners(client); + } } - /** - * Handle an already-active root span at integration setup time. - */ - public notifyRootSpanActive(rootSpan: Span): void { + /** Starts UI profiling (only effective in 'manual' mode and when sampled). */ + public start(): void { + if (this._lifecycleMode === 'trace') { + DEBUG_BUILD && + debug.warn( + '[Profiling] `profileLifecycle` is set to "trace". Calls to `uiProfiler.start()` are ignored in trace mode.', + ); + return; + } + + if (this._isRunning) { + DEBUG_BUILD && debug.warn('[Profiling] Profile session is already running, `uiProfiler.start()` is a no-op.'); + return; + } + if (!this._sessionSampled) { + DEBUG_BUILD && debug.warn('[Profiling] Session is not sampled, `uiProfiler.start()` is a no-op.'); + return; + } + + this._beginProfiling(); + } + + /** Stops UI profiling (only effective in 'manual' mode). */ + public stop(): void { + if (this._lifecycleMode === 'trace') { + DEBUG_BUILD && + debug.warn( + '[Profiling] `profileLifecycle` is set to "trace". Calls to `uiProfiler.stop()` are ignored in trace mode.', + ); + return; + } + + if (!this._isRunning) { + DEBUG_BUILD && debug.warn('[Profiling] Profiler is not running, `uiProfiler.stop()` is a no-op.'); + return; + } + + this._endProfiling(); + } + + /** Handle an already-active root span at integration setup time (used only in trace mode). */ + public notifyRootSpanActive(rootSpan: Span): void { + if (this._lifecycleMode !== 'trace' || !this._sessionSampled) { return; } @@ -78,7 +133,7 @@ export class UIProfiler { return; } - this._activeRootSpanIds.add(spanId); + this._registerTraceRootSpan(spanId); const rootSpanCount = this._activeRootSpanIds.size; @@ -86,20 +141,20 @@ export class UIProfiler { DEBUG_BUILD && debug.log('[Profiling] Detected already active root span during setup. Active root spans now:', rootSpanCount); - this.start(); + this._beginProfiling(); } } /** - * Start profiling if not already running. + * Begin profiling if not already running. */ - public start(): void { + private _beginProfiling(): void { if (this._isRunning) { return; } this._isRunning = true; - DEBUG_BUILD && debug.log('[Profiling] Started profiling with profile ID:', this._profilerId); + DEBUG_BUILD && debug.log('[Profiling] Started profiling with profiler ID:', this._profilerId); // Expose profiler_id to match root spans with profiles getGlobalScope().setContext('profile', { profiler_id: this._profilerId }); @@ -107,7 +162,7 @@ export class UIProfiler { this._startProfilerInstance(); if (!this._profiler) { - DEBUG_BUILD && debug.log('[Profiling] Stopping trace lifecycle profiling.'); + DEBUG_BUILD && debug.log('[Profiling] Failed to start JS Profiler; stopping.'); this._resetProfilerInfo(); return; } @@ -115,15 +170,13 @@ export class UIProfiler { this._startPeriodicChunking(); } - /** - * Stop profiling; final chunk will be collected and sent. - */ - public stop(): void { + /** End profiling session; final chunk will be collected and sent. */ + private _endProfiling(): void { if (!this._isRunning) { return; } - this._isRunning = false; + if (this._chunkTimer) { clearTimeout(this._chunkTimer); this._chunkTimer = undefined; @@ -135,6 +188,12 @@ export class UIProfiler { this._collectCurrentChunk().catch(e => { DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on `stop()`:', e); }); + + // Manual: Clear profiling context so spans outside start()/stop() aren't marked as profiled + // Trace: Profile context is kept for the whole session duration + if (this._lifecycleMode === 'manual') { + getGlobalScope().setContext('profile', {}); + } } /** Trace-mode: attach spanStart/spanEnd listeners. */ @@ -166,7 +225,7 @@ export class UIProfiler { debug.log( `[Profiling] Root span ${spanId} started. Profiling active while there are active root spans (count=${rootSpanCount}).`, ); - this.start(); + this._beginProfiling(); } }); @@ -189,13 +248,13 @@ export class UIProfiler { this._collectCurrentChunk().catch(e => { DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on last `spanEnd`:', e); }); - this.stop(); + this._endProfiling(); } }); } /** - * Resets profiling information from scope and resets running state + * Resets profiling information from scope and resets running state (used on failure) */ private _resetProfilerInfo(): void { this._isRunning = false; @@ -210,7 +269,7 @@ export class UIProfiler { this._rootSpanTimeouts.clear(); } - /** Register root span and schedule safeguard timeout (trace mode). */ + /** Keep track of root spans and schedule safeguard timeout (trace mode). */ private _registerTraceRootSpan(spanId: string): void { this._activeRootSpanIds.add(spanId); const timeout = setTimeout(() => this._onRootSpanTimeout(spanId), MAX_ROOT_SPAN_PROFILE_MS); @@ -222,11 +281,11 @@ export class UIProfiler { */ private _startProfilerInstance(): void { if (this._profiler?.stopped === false) { - return; + return; // already running } const profiler = startJSSelfProfile(); if (!profiler) { - DEBUG_BUILD && debug.log('[Profiling] Failed to start JS Profiler in trace lifecycle.'); + DEBUG_BUILD && debug.log('[Profiling] Failed to start JS Profiler.'); return; } this._profiler = profiler; @@ -283,14 +342,13 @@ export class UIProfiler { this._activeRootSpanIds.delete(rootSpanId); - const rootSpanCount = this._activeRootSpanIds.size; - if (rootSpanCount === 0) { - this.stop(); + if (this._activeRootSpanIds.size === 0) { + this._endProfiling(); } } /** - * Stop the current profiler, convert and send a profile chunk. + * Stop current profiler instance, convert profile to chunk & send. */ private async _collectCurrentChunk(): Promise { const prevProfiler = this._profiler; diff --git a/packages/browser/src/profiling/index.ts b/packages/browser/src/profiling/index.ts new file mode 100644 index 000000000000..5847c070dd48 --- /dev/null +++ b/packages/browser/src/profiling/index.ts @@ -0,0 +1,55 @@ +import type { Profiler } from '@sentry/core'; +import { debug, getClient } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; + +/** + * Starts the Sentry UI profiler. + * This mode is exclusive with the transaction profiler and will only work if the profilesSampleRate is set to a falsy value. + * In UI profiling mode, the profiler will keep reporting profile chunks to Sentry until it is stopped, which allows for continuous profiling of the application. + */ +function startProfiler(): void { + const client = getClient(); + if (!client) { + DEBUG_BUILD && debug.warn('No Sentry client available, profiling is not started'); + return; + } + + const integration = client.getIntegrationByName('BrowserProfiling'); + + if (!integration) { + DEBUG_BUILD && debug.warn('BrowserProfiling integration is not available'); + return; + } + + client.emit('startUIProfiler'); +} + +/** + * Stops the Sentry UI profiler. + * Calls to stop will stop the profiler and flush the currently collected profile data to Sentry. + */ +function stopProfiler(): void { + const client = getClient(); + if (!client) { + DEBUG_BUILD && debug.warn('No Sentry client available, profiling is not started'); + return; + } + + const integration = client.getIntegrationByName('BrowserProfiling'); + if (!integration) { + DEBUG_BUILD && debug.warn('ProfilingIntegration is not available'); + return; + } + + client.emit('stopUIProfiler'); +} + +/** + * Profiler namespace for controlling the JS profiler in 'manual' mode. + * + * Requires the `browserProfilingIntegration` from the `@sentry/browser` package. + */ +export const uiProfiler: Profiler = { + startProfiler, + stopProfiler, +}; diff --git a/packages/browser/src/profiling/integration.ts b/packages/browser/src/profiling/integration.ts index 7cd1886e636d..84cd33588320 100644 --- a/packages/browser/src/profiling/integration.ts +++ b/packages/browser/src/profiling/integration.ts @@ -14,7 +14,6 @@ import { getActiveProfilesCount, hasLegacyProfiling, isAutomatedPageLoadSpan, - shouldProfileSession, shouldProfileSpanLegacy, takeProfileFromGlobalCache, } from './utils'; @@ -26,12 +25,14 @@ const _browserProfilingIntegration = (() => { name: INTEGRATION_NAME, setup(client) { const options = client.getOptions() as BrowserOptions; + const profiler = new UIProfiler(); if (!hasLegacyProfiling(options) && !options.profileLifecycle) { // Set default lifecycle mode options.profileLifecycle = 'manual'; } + // eslint-disable-next-line deprecation/deprecation if (hasLegacyProfiling(options) && !options.profilesSampleRate) { DEBUG_BUILD && debug.log('[Profiling] Profiling disabled, no profiling options found.'); return; @@ -49,14 +50,15 @@ const _browserProfilingIntegration = (() => { // UI PROFILING (Profiling V2) if (!hasLegacyProfiling(options)) { - const sessionSampled = shouldProfileSession(options); - if (!sessionSampled) { - DEBUG_BUILD && debug.log('[Profiling] Session not sampled. Skipping lifecycle profiler initialization.'); - } - const lifecycleMode = options.profileLifecycle; - if (lifecycleMode === 'trace') { + // Registering hooks in all lifecycle modes to be able to notify users in case they want to start/stop the profiler manually in `trace` mode + client.on('startUIProfiler', () => profiler.start()); + client.on('stopUIProfiler', () => profiler.stop()); + + if (lifecycleMode === 'manual') { + profiler.initialize(client); + } else if (lifecycleMode === 'trace') { if (!hasSpansEnabled(options)) { DEBUG_BUILD && debug.warn( @@ -65,12 +67,11 @@ const _browserProfilingIntegration = (() => { return; } - const traceLifecycleProfiler = new UIProfiler(); - traceLifecycleProfiler.initialize(client, sessionSampled); + profiler.initialize(client); // If there is an active, sampled root span already, notify the profiler if (rootSpan) { - traceLifecycleProfiler.notifyRootSpanActive(rootSpan); + profiler.notifyRootSpanActive(rootSpan); } // In case rootSpan is created slightly after setup -> schedule microtask to re-check and notify. @@ -78,7 +79,7 @@ const _browserProfilingIntegration = (() => { const laterActiveSpan = getActiveSpan(); const laterRootSpan = laterActiveSpan && getRootSpan(laterActiveSpan); if (laterRootSpan) { - traceLifecycleProfiler.notifyRootSpanActive(laterRootSpan); + profiler.notifyRootSpanActive(laterRootSpan); } }, 0); } diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index ed794a40a98b..c50c76c84de4 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -651,8 +651,10 @@ export function shouldProfileSpanLegacy(span: Span): boolean { return false; } - // @ts-expect-error profilesSampleRate is not part of the browser options yet - const profilesSampleRate: number | boolean | undefined = options.profilesSampleRate; + // eslint-disable-next-line deprecation/deprecation + const profilesSampleRate = (options as BrowserOptions).profilesSampleRate as + | BrowserOptions['profilesSampleRate'] + | boolean; // Since this is coming from the user (or from a function provided by the user), who knows what we might get. (The // only valid values are booleans or numbers between 0 and 1.) @@ -688,18 +690,21 @@ export function shouldProfileSpanLegacy(span: Span): boolean { } /** - * Determine if a profile should be created for the current session (lifecycle profiling mode). + * Determine if a profile should be created for the current session. */ export function shouldProfileSession(options: BrowserOptions): boolean { // If constructor failed once, it will always fail, so we can early return. if (PROFILING_CONSTRUCTOR_FAILED) { if (DEBUG_BUILD) { - debug.log('[Profiling] Profiling has been disabled for the duration of the current user session.'); + debug.log( + '[Profiling] Profiling has been disabled for the duration of the current user session as the JS Profiler could not be started.', + ); } return false; } - if (options.profileLifecycle !== 'trace') { + if (options.profileLifecycle !== 'trace' && options.profileLifecycle !== 'manual') { + DEBUG_BUILD && debug.warn('[Profiling] Session not sampled. Invalid `profileLifecycle` option.'); return false; } @@ -724,6 +729,7 @@ export function shouldProfileSession(options: BrowserOptions): boolean { * Checks if legacy profiling is configured. */ export function hasLegacyProfiling(options: BrowserOptions): boolean { + // eslint-disable-next-line deprecation/deprecation return typeof options.profilesSampleRate !== 'undefined'; } diff --git a/packages/browser/test/profiling/UIProfiler.test.ts b/packages/browser/test/profiling/UIProfiler.test.ts index f28880960256..6872e1e1beff 100644 --- a/packages/browser/test/profiling/UIProfiler.test.ts +++ b/packages/browser/test/profiling/UIProfiler.test.ts @@ -3,8 +3,20 @@ */ import * as Sentry from '@sentry/browser'; -import type { Span } from '@sentry/core'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { type Span, debug } from '@sentry/core'; +import { type Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BrowserOptions } from '../../src/index'; + +function getBaseOptionsForTraceLifecycle(sendMock: Mock , enableTracing = true): BrowserOptions { + return { + dsn: 'https://public@o.ingest.sentry.io/1', + ...(enableTracing ? { tracesSampleRate: 1 } : {}), + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send: sendMock }), + }; +} describe('Browser Profiling v2 trace lifecycle', () => { afterEach(async () => { @@ -48,12 +60,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { Sentry.init({ // tracing disabled - dsn: 'https://public@o.ingest.sentry.io/1', - profileSessionSampleRate: 1, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - // no tracesSampleRate/tracesSampler - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + ...getBaseOptionsForTraceLifecycle(send, false), }); // warning is logged by our debug logger only when DEBUG_BUILD, so just assert no throw and no profiler @@ -79,12 +86,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { const send = vi.fn().mockResolvedValue(undefined); Sentry.init({ - dsn: 'https://public@o.ingest.sentry.io/1', - tracesSampleRate: 1, - profileSessionSampleRate: 1, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + ...getBaseOptionsForTraceLifecycle(send), }); let spanRef: any; @@ -112,12 +114,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { const send = vi.fn().mockResolvedValue(undefined); Sentry.init({ - dsn: 'https://public@o.ingest.sentry.io/1', - tracesSampleRate: 1, - profileSessionSampleRate: 1, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + ...getBaseOptionsForTraceLifecycle(send), }); let spanA: any; @@ -159,12 +156,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { const send = vi.fn().mockResolvedValue(undefined); Sentry.init({ - dsn: 'https://public@o.ingest.sentry.io/1', - tracesSampleRate: 1, - profileSessionSampleRate: 1, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + ...getBaseOptionsForTraceLifecycle(send), }); let spanRef: any; @@ -195,12 +187,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { const send = vi.fn().mockResolvedValue(undefined); Sentry.init({ - dsn: 'https://public@o.ingest.sentry.io/1', - tracesSampleRate: 1, - profileSessionSampleRate: 1, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + ...getBaseOptionsForTraceLifecycle(send), }); let spanRef: any; @@ -255,12 +242,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { const send = vi.fn().mockResolvedValue(undefined); Sentry.init({ - dsn: 'https://public@o.ingest.sentry.io/1', - tracesSampleRate: 1, - profileSessionSampleRate: 1, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + ...getBaseOptionsForTraceLifecycle(send), }); Sentry.startSpanManual({ name: 'root-manual-never-ends', parentSpan: null, forceTransaction: true }, _span => { @@ -308,12 +290,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { const send = vi.fn().mockResolvedValue(undefined); Sentry.init({ - dsn: 'https://public@o.ingest.sentry.io/1', - tracesSampleRate: 1, - profileSessionSampleRate: 1, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + ...getBaseOptionsForTraceLifecycle(send), }); Sentry.startSpanManual({ name: 'root-manual-never-ends', parentSpan: null, forceTransaction: true }, _span => { @@ -375,12 +352,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { const send = vi.fn().mockResolvedValue(undefined); Sentry.init({ - dsn: 'https://public@o.ingest.sentry.io/1', - tracesSampleRate: 1, - profileSessionSampleRate: 1, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + ...getBaseOptionsForTraceLifecycle(send), }); Sentry.startSpan({ name: 'root-for-context', parentSpan: null, forceTransaction: true }, () => { @@ -440,12 +412,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { const send = vi.fn().mockResolvedValue(undefined); Sentry.init({ - dsn: 'https://public@o.ingest.sentry.io/1', - tracesSampleRate: 1, - profileSessionSampleRate: 1, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + ...getBaseOptionsForTraceLifecycle(send), }); Sentry.startSpan({ name: 'rootSpan-1', parentSpan: null, forceTransaction: true }, () => { @@ -499,12 +466,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { const send = vi.fn().mockResolvedValue(undefined); Sentry.init({ - dsn: 'https://public@o.ingest.sentry.io/1', - tracesSampleRate: 1, - profileSessionSampleRate: 1, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + ...getBaseOptionsForTraceLifecycle(send), }); Sentry.startSpan({ name: 'rootSpan-chunk-1', parentSpan: null, forceTransaction: true }, () => { @@ -563,12 +525,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { // Session 1 const send1 = vi.fn().mockResolvedValue(undefined); Sentry.init({ - dsn: 'https://public@o.ingest.sentry.io/1', - tracesSampleRate: 1, - profileSessionSampleRate: 1, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send: send1 }), + ...getBaseOptionsForTraceLifecycle(send1), }); Sentry.startSpan({ name: 'session-1-rootSpan', parentSpan: null, forceTransaction: true }, () => { @@ -598,12 +555,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { // Session 2 (new init simulates new user session) const send2 = vi.fn().mockResolvedValue(undefined); Sentry.init({ - dsn: 'https://public@o.ingest.sentry.io/1', - tracesSampleRate: 1, - profileSessionSampleRate: 1, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send: send2 }), + ...getBaseOptionsForTraceLifecycle(send2), }); Sentry.startSpan({ name: 'session-2-rootSpan', parentSpan: null, forceTransaction: true }, () => { @@ -628,4 +580,271 @@ describe('Browser Profiling v2 trace lifecycle', () => { } }); }); + + it('calling start and stop in trace lifecycle prints warnings', async () => { + const { stop } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + const debugWarnSpy = vi.spyOn(debug, 'warn'); + + Sentry.init({ + ...getBaseOptionsForTraceLifecycle(send), + debug: true, + }); + + Sentry.uiProfiler.startProfiler(); + Sentry.uiProfiler.startProfiler(); + + expect(debugWarnSpy).toHaveBeenCalledWith( + '[Profiling] `profileLifecycle` is set to "trace". Calls to `uiProfiler.start()` are ignored in trace mode.', + ); + + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + debugWarnSpy.mockClear(); + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + expect(stop).toHaveBeenCalledTimes(0); + expect(debugWarnSpy).toHaveBeenCalledWith( + '[Profiling] `profileLifecycle` is set to "trace". Calls to `uiProfiler.stop()` are ignored in trace mode.', + ); + }); +}); + +function getBaseOptionsForManualLifecycle(sendMock: Mock , enableTracing = true): BrowserOptions { + return { + dsn: 'https://public@o.ingest.sentry.io/1', + ...(enableTracing ? { tracesSampleRate: 1 } : {}), + profileSessionSampleRate: 1, + profileLifecycle: 'manual', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send: sendMock }), + }; +} + +describe('Browser Profiling v2 manual lifecycle', () => { + afterEach(async () => { + const client = Sentry.getClient(); + await client?.close(); + // reset profiler constructor + (window as any).Profiler = undefined; + vi.restoreAllMocks(); + }); + + function mockProfiler() { + const stop = vi.fn().mockResolvedValue({ + frames: [{ name: 'f' }], + stacks: [{ frameId: 0 }], + samples: [{ timestamp: 0 }, { timestamp: 10 }], + resources: [], + }); + + class MockProfilerImpl { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return stop(); + } + addEventListener() {} + } + + const mockConstructor = vi.fn().mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => { + return new MockProfilerImpl(opts); + }); + + (window as any).Profiler = mockConstructor; + return { stop, mockConstructor }; + } + + it('starts and stops a profile session', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + ...getBaseOptionsForManualLifecycle(send), + }); + + const client = Sentry.getClient(); + expect(client).toBeDefined(); + + Sentry.uiProfiler.startProfiler(); + expect(mockConstructor).toHaveBeenCalledTimes(1); + + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + expect(stop).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledTimes(1); + const envelopeHeader = send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]; + expect(envelopeHeader?.type).toBe('profile_chunk'); + }); + + it('calling start and stop while profile session is running prints warnings', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + const debugWarnSpy = vi.spyOn(debug, 'warn'); + + Sentry.init({ + ...getBaseOptionsForManualLifecycle(send), + debug: true, + }); + + Sentry.uiProfiler.startProfiler(); + Sentry.uiProfiler.startProfiler(); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + expect(debugWarnSpy).toHaveBeenCalledWith( + '[Profiling] Profile session is already running, `uiProfiler.start()` is a no-op.', + ); + + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + debugWarnSpy.mockClear(); + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + expect(stop).toHaveBeenCalledTimes(1); + expect(debugWarnSpy).toHaveBeenCalledWith('[Profiling] Profiler is not running, `uiProfiler.stop()` is a no-op.'); + }); + + it('profileSessionSampleRate is required', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + const debugWarnSpy = vi.spyOn(debug, 'warn'); + + Sentry.init({ + ...getBaseOptionsForManualLifecycle(send), + profileSessionSampleRate: undefined, + }); + + Sentry.uiProfiler.startProfiler(); + expect(debugWarnSpy).toHaveBeenCalledWith( + '[Profiling] Invalid sample rate. Sample rate must be a boolean or a number between 0 and 1. Got undefined of type "undefined".', + ); + expect(debugWarnSpy).toHaveBeenCalledWith('[Profiling] Session is not sampled, `uiProfiler.start()` is a no-op.'); + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + expect(mockConstructor).not.toHaveBeenCalled(); + expect(stop).not.toHaveBeenCalled(); + }); + + it('does not start profiler when profileSessionSampleRate is 0', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + ...getBaseOptionsForManualLifecycle(send), + profileSessionSampleRate: 0, + }); + + Sentry.uiProfiler.startProfiler(); + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + expect(mockConstructor).not.toHaveBeenCalled(); + expect(stop).not.toHaveBeenCalled(); + }); + + describe('envelope', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it('sends a profile_chunk envelope type', async () => { + const stop = vi.fn().mockResolvedValue({ + frames: [{ name: 'f' }], + stacks: [{ frameId: 0 }], + samples: [{ timestamp: 0 }, { timestamp: 10 }], + resources: [], + }); + + class MockProfilerImpl { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return stop(); + } + addEventListener() {} + } + + (window as any).Profiler = vi + .fn() + .mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => new MockProfilerImpl(opts)); + + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + ...getBaseOptionsForManualLifecycle(send), + }); + + const client = Sentry.getClient(); + + Sentry.uiProfiler.startProfiler(); + await new Promise(resolve => setTimeout(resolve, 10)); + Sentry.uiProfiler.stopProfiler(); + + await client?.flush(1000); + + expect(send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]).toMatchObject({ + type: 'profile_chunk', + }); + + expect(send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[1]).toMatchObject({ + profiler_id: expect.any(String), + chunk_id: expect.any(String), + profile: expect.objectContaining({ + stacks: expect.any(Array), + }), + }); + }); + + it('reuses the same profiler_id while profiling across multiple stop/start calls', async () => { + mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + ...getBaseOptionsForManualLifecycle(send), + }); + + // 1. profiling cycle + Sentry.uiProfiler.startProfiler(); + Sentry.startSpan({ name: 'manual-span-1', parentSpan: null, forceTransaction: true }, () => {}); + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + // Not profiled -> should not have profile context + Sentry.startSpan({ name: 'manual-span-between', parentSpan: null, forceTransaction: true }, () => {}); + + // 2. profiling cycle + Sentry.uiProfiler.startProfiler(); + Sentry.startSpan({ name: 'manual-span-2', parentSpan: null, forceTransaction: true }, () => {}); + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + const client = Sentry.getClient(); + await client?.flush(1000); + + const calls = send.mock.calls; + const transactionEvents = calls + .filter(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction') + .map(call => call?.[0]?.[1]?.[0]?.[1]); + + expect(transactionEvents.length).toBe(3); + + const firstProfilerId = transactionEvents[0]?.contexts?.profile?.profiler_id; + expect(typeof firstProfilerId).toBe('string'); + + // Middle transaction (not profiled) + expect(transactionEvents[1]?.contexts?.profile?.profiler_id).toBeUndefined(); + + const thirdProfilerId = transactionEvents[2]?.contexts?.profile?.profiler_id; + expect(typeof thirdProfilerId).toBe('string'); + expect(firstProfilerId).toBe(thirdProfilerId); // same profiler_id across session + }); + }); }); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index b7e0cab509c1..ef05750009c3 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -813,6 +813,24 @@ export abstract class Client { callback: (request: unknown, response: unknown, normalizedRequest: RequestEventData) => void, ): () => void; + /** + * A hook that is called when the UI Profiler should start profiling. + * + * This hook is called when running `Sentry.uiProfiler.startProfiler()`. + * + * @returns {() => void} A function that, when executed, removes the registered callback. + */ + public on(hook: 'startUIProfiler', callback: () => void): () => void; + + /** + * A hook that is called when the UI Profiler should stop profiling. + * + * This hook is called when running `Sentry.uiProfiler.stopProfiler()`. + * + * @returns {() => void} A function that, when executed, removes the registered callback. + */ + public on(hook: 'stopUIProfiler', callback: () => void): () => void; + /** * Register a hook on this client. */ @@ -1029,6 +1047,16 @@ export abstract class Client { normalizedRequest: RequestEventData, ): void; + /** + * Emit a hook event for starting the UI Profiler. + */ + public emit(hook: 'startUIProfiler'): void; + + /** + * Emit a hook event for stopping the UI Profiler. + */ + public emit(hook: 'stopUIProfiler'): void; + /** * Emit a hook that was previously registered via `on()`. */ diff --git a/packages/core/src/profiling.ts b/packages/core/src/profiling.ts index 407c4a07c53c..e2e2c34e38cc 100644 --- a/packages/core/src/profiling.ts +++ b/packages/core/src/profiling.ts @@ -65,6 +65,11 @@ function stopProfiler(): void { integration._profiler.stop(); } +/** + * Profiler namespace for controlling the profiler in 'manual' mode. + * + * Requires the `nodeProfilingIntegration` from the `@sentry/profiling-node` package. + */ export const profiler: Profiler = { startProfiler, stopProfiler, diff --git a/packages/core/src/types-hoist/browseroptions.ts b/packages/core/src/types-hoist/browseroptions.ts index 18bbd46af09c..39b414d5140b 100644 --- a/packages/core/src/types-hoist/browseroptions.ts +++ b/packages/core/src/types-hoist/browseroptions.ts @@ -18,10 +18,11 @@ export type BrowserClientReplayOptions = { }; export type BrowserClientProfilingOptions = { - // todo: add deprecation warning for profilesSampleRate: @deprecated Use `profileSessionSampleRate` and `profileLifecycle` instead. /** * The sample rate for profiling * 1.0 will profile all transactions and 0 will profile none. + * + * @deprecated Use `profileSessionSampleRate` and `profileLifecycle` instead. */ profilesSampleRate?: number; From 6240191d1fef08423b9928846fc4a9a7aa7c8da5 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 24 Nov 2025 15:53:32 +0100 Subject: [PATCH 28/32] feat(core): Use `maxValueLength` on error messages (#18301) It can happen that error messages are too long and exceed the maximum envelope size (mentioned in https://github.com/getsentry/sentry-javascript/issues/18219). `maxValueLength` now also checks for long error messages and truncates them. --- packages/core/src/utils/prepareEvent.ts | 13 ++++++++-- packages/core/test/lib/client.test.ts | 32 +++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/core/src/utils/prepareEvent.ts b/packages/core/src/utils/prepareEvent.ts index 9a4c4685e839..fd1cb62440f4 100644 --- a/packages/core/src/utils/prepareEvent.ts +++ b/packages/core/src/utils/prepareEvent.ts @@ -147,8 +147,17 @@ export function applyClientOptions(event: Event, options: ClientOptions): void { } const request = event.request; - if (request?.url) { - request.url = maxValueLength ? truncate(request.url, maxValueLength) : request.url; + if (request?.url && maxValueLength) { + request.url = truncate(request.url, maxValueLength); + } + + if (maxValueLength) { + event.exception?.values?.forEach(exception => { + if (exception.value) { + // Truncates error messages + exception.value = truncate(exception.value, maxValueLength); + } + }); } } diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index 19ef8a95dff5..2a2d77171880 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -21,7 +21,6 @@ import type { ErrorEvent, Event, TransactionEvent } from '../../src/types-hoist/ import type { SpanJSON } from '../../src/types-hoist/span'; import * as debugLoggerModule from '../../src/utils/debug-logger'; import * as miscModule from '../../src/utils/misc'; -import * as stringModule from '../../src/utils/string'; import * as timeModule from '../../src/utils/time'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; import { AdHocIntegration, AsyncTestIntegration, TestIntegration } from '../mocks/integration'; @@ -37,7 +36,6 @@ const clientProcess = vi.spyOn(TestClient.prototype as any, '_process'); vi.spyOn(miscModule, 'uuid4').mockImplementation(() => '12312012123120121231201212312012'); vi.spyOn(debugLoggerModule, 'consoleSandbox').mockImplementation(cb => cb()); -vi.spyOn(stringModule, 'truncate').mockImplementation(str => str); vi.spyOn(timeModule, 'dateTimestampInSeconds').mockImplementation(() => 2020); describe('Client', () => { @@ -263,6 +261,36 @@ describe('Client', () => { ); }); + test('does not truncate exception values by default', () => { + const exceptionMessageLength = 10_000; + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); + const client = new TestClient(options); + + client.captureException(new Error('a'.repeat(exceptionMessageLength))); + expect(TestClient.instance!.event).toEqual( + expect.objectContaining({ + exception: { + values: [{ type: 'Error', value: 'a'.repeat(exceptionMessageLength) }], + }, + }), + ); + }); + + test('truncates exception values according to `maxValueLength` option', () => { + const maxValueLength = 10; + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, maxValueLength }); + const client = new TestClient(options); + + client.captureException(new Error('a'.repeat(50))); + expect(TestClient.instance!.event).toEqual( + expect.objectContaining({ + exception: { + values: [{ type: 'Error', value: `${'a'.repeat(maxValueLength)}...` }], + }, + }), + ); + }); + test('sets the correct lastEventId', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); From 4b92c64b75ffb85f31a516ca093ab77a5e55c15d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 24 Nov 2025 17:41:05 +0200 Subject: [PATCH 29/32] fix(nextjs): universal random tunnel path support (#18257) Summary for changelog: The `tunnelRoute: true` option didn't work well with Turbopack due to repeated runs of the config files leading to different tunnel URLs in client, server and edge runtimes, this PR fixes that while also fixing Sentry requests spans not being dropped by the sampler. When using Next.js with Turbopack and the Sentry tunnel route feature (`tunnelRoute: true`), several issues prevented events from being sent properly: ### 1. Tunnel Route Consistency (Turbopack) **Problem**: Random tunnel routes were generated separately for client and server builds in Turbopack. **Solution**: Implemented processs-level caching in `withSentryConfig.ts`: - Extract tunnel route resolution into `resolveTunnelRoute()` function - Use `process.env` to store the random tunnel value across server/client builds. ### 2. Filter Tunnel Request Spans **Problem**: Requests to the tunnel route (before rewrite) and to Sentry ingest URLs (after rewrite) were creating spans that polluted Sentry with internal instrumentation noise, spans were being created by the middleware and OTEL node.js fetch instrumentation. **Solution**: Implemented server-side span filtering: - Created `dropMiddlewareTunnelRequests()` utility to detect and drop tunnel-related spans - Filter spans originating from `Middleware.execute` (Next.js middleware) - Filter spans originating from `auto.http.otel.node_fetch` (Node.js fetch instrumentation) - Check both local tunnel paths and Sentry ingest URLs (using `isSentryRequestSpan` from `@sentry/opentelemetry`) - Mark matching spans with `TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION` to prevent them from being sent - I tried `beforeSampling` hook but it didn't work for some reason, so I stuck with the drop attribute. ---- The final issue was excluding the tunnel requests from the middleware/proxy, but there are many blockers for a solution: 1. The `config` must be statically analyzable, so we cannot expose `withSentryMiddlewareConfig` wrapper of any kind. 2. Warning the user doesn't help much because they can't do anything about it since the tunnel route is random. 3. Tested out writing a loader for turbopack/webpack to inject the tunnel into the matcher as an array but user existing matcher can match still. 4. Only way is to inject an exclusion match into the user existing matcher, if it is an array then we need to inject it into each single entry. I may explore this further later with a loader for both webpack/turbopack, and figure out a reliable way to inject the negative matchers into the user expressions. --- .../nextjs-16-tunnel/.gitignore | 46 ++++++ .../test-applications/nextjs-16-tunnel/.npmrc | 4 + .../nextjs-16-tunnel/app/favicon.ico | Bin 0 -> 25931 bytes .../nextjs-16-tunnel/app/global-error.tsx | 23 +++ .../nextjs-16-tunnel/app/layout.tsx | 7 + .../nextjs-16-tunnel/app/page.tsx | 3 + .../nextjs-16-tunnel/eslint.config.mjs | 19 +++ .../instrumentation-client.ts | 12 ++ .../nextjs-16-tunnel/instrumentation.ts | 13 ++ .../nextjs-16-tunnel/next.config.ts | 9 ++ .../nextjs-16-tunnel/package.json | 63 +++++++++ .../nextjs-16-tunnel/playwright.config.mjs | 29 ++++ .../nextjs-16-tunnel/proxy.ts | 11 ++ .../nextjs-16-tunnel/public/file.svg | 1 + .../nextjs-16-tunnel/public/globe.svg | 1 + .../nextjs-16-tunnel/public/next.svg | 1 + .../nextjs-16-tunnel/public/vercel.svg | 1 + .../nextjs-16-tunnel/public/window.svg | 1 + .../nextjs-16-tunnel/sentry.edge.config.ts | 11 ++ .../nextjs-16-tunnel/sentry.server.config.ts | 11 ++ .../nextjs-16-tunnel/start-event-proxy.mjs | 14 ++ .../tests/tunnel-route.test.ts | 132 ++++++++++++++++++ .../nextjs-16-tunnel/tsconfig.json | 27 ++++ .../utils/dropMiddlewareTunnelRequests.ts | 59 ++++++++ .../turbopack/constructTurbopackConfig.ts | 12 +- .../turbopack/generateValueInjectionRules.ts | 7 + .../nextjs/src/config/withSentryConfig.ts | 39 +++++- packages/nextjs/src/edge/index.ts | 24 ++++ packages/nextjs/src/server/index.ts | 3 + 29 files changed, 576 insertions(+), 7 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/favicon.ico create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/global-error.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/eslint.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/next.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/proxy.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/file.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/globe.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/next.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/vercel.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/window.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tsconfig.json create mode 100644 packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.gitignore new file mode 100644 index 000000000000..ae044ec5ad53 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.gitignore @@ -0,0 +1,46 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +event-dumps + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# Sentry Config File +.env.sentry-build-plugin diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.npmrc b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.npmrc new file mode 100644 index 000000000000..a3160f4de175 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.npmrc @@ -0,0 +1,4 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 +public-hoist-pattern[]=*import-in-the-middle* +public-hoist-pattern[]=*require-in-the-middle* diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/favicon.ico b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 zcmeHv30#a{`}aL_*G&7qml|y<+KVaDM2m#dVr!KsA!#An?kSQM(q<_dDNCpjEux83 zLb9Z^XxbDl(w>%i@8hT6>)&Gu{h#Oeyszu?xtw#Zb1mO{pgX9699l+Qppw7jXaYf~-84xW z)w4x8?=youko|}Vr~(D$UX IbiXABHh`p1?nn8Po~fxRJv}|0e(BPs|G`(TT%kKVJAdg5*Z|x0leQq0 zkdUBvb#>9F()jo|T~kx@OM8$9wzs~t2l;K=woNssA3l6|sx2r3+kdfVW@e^8e*E}v zA1y5{bRi+3Z`uD3{F7LgFJDdvm;nJilkzDku>BwXH(8ItVCXk*-lSJnR?-2UN% hJ){&rlvg`CDTj z)Bzo!3v7Ou#83zEDEFcKt(f1E0~=rqeEbTnMvWR#{+9pg%7G8y>u1OVRUSoox-ovF z2Ydma(;=YuBY(eI|04{hXzZD6_f(v~H;C~y5=DhAC{MMS>2fm~1H_t2$56pc$NH8( z5bH|<)71dV-_oCHIrzrT`2s-5w_+2CM0$95I6X8p^r!gHp+j_gd;9O<1~CEQQGS8) zS9Qh3#p&JM-G8rHekNmKVewU;pJRcTAog68KYo^dRo}(M>36U4Us zfgYWSiHZL3;lpWT= zNAW>Dh#mB!_@Lg%$ms8N-;aPqMn+C2HqZgz&9~Eu z4|Kp<`$q)Uw1R?y(~S>ePdonHxpV1#eSP1B;Ogo+-Pk}6#0GsZZ5!||ev2MGdh}_m z{DeR7?0-1^zVs&`AV6Vt;r3`I`OI_wgs*w=eO%_#7Kepl{B @xiyCANc(l zzIyd4y|c6PXWq9-|KM8(zIk8LPk(>a)zyFWjhT!$HJ$qX1vo@d25W< fvZQ2zUz5WRc(UnFMKHwe1| zWmlB1qdbiA(C0jmnV<}GfbKtmcu^2*P^O? MBLZKt|As~ge8&AAO~2K@zbXelK|4T<{|y4`raF{=72kC2Kn(L4YyenWgrPiv z@^mr$t{#X5VuIMeL!7Ab6_kG$&5p*Z{+?5U|TZ`B!7llpVmp@skYz&n^8QfPJzL z0G6K_OJM9x+Wu2gfN45phANGt{7=C>i34CV{Xqlx(fWpeAoj^N0Biu`w+MVcCUyU* zDZuzO0>4Z6fbu^T_arWW5n!E45vX8N=bxTVeFoep_G#VmNlQzAI_KTIc{6>c+04vr zx@W}zE5JNSU>!THJ{J=cqjz+4{L4A{Ob9$ZJ*S1?Ggg3klFp!+Y1@K+pK1DqI|_gq z5ZDXVpge8-cs!o|;K73#YXZ3AShj50wBvuq3NTOZ`M&qtjj#GOFfgExjg8Gn8>Vq5 z`85n+9|!iLCZF5$HJ$Iu($dm?8~-ofu}tEc+-pyke=3!im#6pk_Wo8IA|fJwD&~~F zc16osQ)EBo58U7XDuMexaPRjU@h8tXe%S{fA0NH3vGJFhuyyO!Uyl2^&EOpX{9As0 zWj+P>{@}jxH)8|r;2HdupP!vie{sJ28b&bo!8`D^x}TE$%zXNb^X1p@0PJ86`dZyj z%ce7*{^oo+6%&~I!8hQy-vQ7E)0t0ybH4l%KltWOo~8cO`T=157JqL(oq_rC%ea&4 z2NcTJe-HgFjNg-gZ$6!Y`SMHrlj}Etf7 ?r!zQTPPSv}{so2e>Fjs1{ gzk~LGeesX%r(Lh6rbhSo_n)@@G-FTQy93;l#E)hgP@d_SGvyCp0~o(Y;Ee8{ zdVUDbHm5`2taPUOY^MAGOw* >=s7=Gst=D+p+2yON!0%Hk` zz5mAhyT4lS*T3LS^WSxUy86q&GnoHxzQ6vm8)VS}_zuqG?+3td68_x;etQAdu@sc6 zQJ&5|4(I?~3d-QOAODHpZ=hlSg(lBZ!JZWCtHHSj`0Wh93-Uk)_S%zsJ~aD>{`A0~ z9{AG(e|q3g5B%wYKRxiL2Y$8(4w 6bzchKuloQW#e&S3n+P- z8!ds-%f;TJ1>)v)##>gd{PdS2Oc3VaR`fr=`O8QIO(6(N!A?pr5C#6fc~Ge@N%Vvu zaoAX2&(a6eWy_q&UwOhU)|P3J0Qc%OdhzW=F4D|pt0E4osw;%<%Dn58hAWD^XnZD= z>9~H(3bmLtxpF?a7su6J7M*x1By7YSUbxGi)Ot0P77`}P 3{)&5Un{KD?`-e?r21!4vTTnN(4Y6Lin?UkSM z`MXCTC1@4A4~mvz%Rh2&EwY))LeoT=*`tMoqcEXI>TZU9WTP#l?uFv+@Dn~b(>xh2 z;>B?;Tz2SR&KVb>vGiBSB`@U7VIWFSo=LDSb9F{GF^DbmWAfpms8Sx9OX4CnBJca3 zlj9(x!dIjN?OG1X4l*imJNvRCk}F%!?SOfiOq5y^mZW)jFL@ a|r-@d#f7 z2gmU8L3IZq0ynIws=}~m^#@&C%J6QFo~Mo4V`>v7MI-_!EBMMtb%_M&kvAaN)@ZVw z+`toz&WG#HkWDjnZE!6nk{e-oFdL^$YnbOCN}JC&{$#$O27@|Tn-skXr)2ml2~O!5 zX+gYoxhoc7qoU?C^3~&!U?kRFtnSEecWuH0B0OvLodgUAi}8p1 zrO6RSXHH}DMc$&|?D004 DiOVMHV8kXCP@7NKB zgaZq^^O<7PoKEp72kby@W0Z!Y*A y{&vfg#C&gG@YVR9g?FEocMUi1gSN$+V+ayF45{a zuDZDTN}mS|;BO%gEf}pjBfN2-gIrU#G5~cucA;dokXW89%>AyXJJI z9X4Ul IWA|ZYHgbI z5?oFk@A=Ik7lrEQPDH!H+b`7_Y~aDb_qa=B2^Y&Ow41cU=4WDd40dp5(QS-WMN-=Y z9g;6_-JdNU;|6cPwf$ak*aJIcwL@1n$#l~zi{c{EW?T;DaW*E8DYq?Umtz{nJ&w-M zEMyT DrC&9K$d|kZe2#ws6)L=7K+{ zQw{XnV6UC$6-rW0emqm8wJoeZK)wJIcV?dST}Z;G0Arq{dVDu0&4kd%N!3F1*;*pW zR&qUiFzK=@44#QGw7k1`3t_d8&*kBV->O##t|tonFc2YWrL7_eqg+=+k;!F-`^b8> z#KWCE8%u4k@EprxqiV$VmmtiWxDLgnGu$Vs<8rppV5E ajBXL4nyyZM$SWVm!wnCj-B!Wjqj5-5dNXukI2$$|Bu3Lrw}z65Lc=1G z^-#WuQOj$hwNGG?*CM_TO8Bg-1+qc>J7k5c51U8g?ZU5n?HYor;~JIjoWH-G>AoUP ztrWWLbRNqIjW#RT*WqZgPJXU7C)VaW5}MiijYbABmzoru6EmQ*N8cVK7a3|aOB#O& zBl8JY2WKfmj;h#Q!pN%9o@VNLv{OUL?rixHwOZuvX7{IJ{(EdPpuVFoQqIOa7giLVkBOKL@^smUA!tZ1CKRK}#SSM)iQHk)*R~?M!qkCruaS!#oIL1c z ?J ;U~&FfH#*98^G?i}pA{ z9Jg36t4=%6mhY(quYq*vSxptes9qy|7xSlH?G=S@>u>Ebe;|LVhs~@+06N<4CViBk zUiY$thvX;>Tby6z9Y1e dAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJw b z_^v8bbg` SAn{I*4bH$u(RZ6*x UhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=p C^ S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk( $?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU ^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvh CL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c 70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397* _cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111a H}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*I cmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU &68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-= A= yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v #ix45EVrcEhr>!NMhprl $InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~ &^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7< 4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}sc Zlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+ 9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2 `1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M =hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S( O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/global-error.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/global-error.tsx new file mode 100644 index 000000000000..20c175015b03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/global-error.tsx @@ -0,0 +1,23 @@ +'use client'; + +import * as Sentry from '@sentry/nextjs'; +import NextError from 'next/error'; +import { useEffect } from 'react'; + +export default function GlobalError({ error }: { error: Error & { digest?: string } }) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( + + + {/* `NextError` is the default Next.js error page component. Its type + definition requires a `statusCode` prop. However, since the App Router + does not expose status codes for errors, we simply pass 0 to render a + generic error message. */} + + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/layout.tsx new file mode 100644 index 000000000000..c8f9cee0b787 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/page.tsx new file mode 100644 index 000000000000..f28a670096bf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return Next.js 16 Tunnel Route Test
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/eslint.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/eslint.config.mjs new file mode 100644 index 000000000000..60f7af38f6c2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/eslint.config.mjs @@ -0,0 +1,19 @@ +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { FlatCompat } from '@eslint/eslintrc'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends('next/core-web-vitals', 'next/typescript'), + { + ignores: ['node_modules/**', '.next/**', 'out/**', 'build/**', 'next-env.d.ts'], + }, +]; + +export default eslintConfig; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts new file mode 100644 index 000000000000..d40b790f18a5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + // Use a fake but properly formatted Sentry SaaS DSN for tunnel route testing + dsn: 'https://public@o12345.ingest.us.sentry.io/67890', + // No tunnel option - using tunnelRoute from withSentryConfig + tracesSampleRate: 1.0, + sendDefaultPii: true, +}); + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation.ts new file mode 100644 index 000000000000..964f937c439a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/nextjs'; + +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + await import('./sentry.server.config'); + } + + if (process.env.NEXT_RUNTIME === 'edge') { + await import('./sentry.edge.config'); + } +} + +export const onRequestError = Sentry.captureRequestError; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/next.config.ts new file mode 100644 index 000000000000..cad68b926a58 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/next.config.ts @@ -0,0 +1,9 @@ +import { withSentryConfig } from '@sentry/nextjs'; +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = {}; + +export default withSentryConfig(nextConfig, { + silent: true, + tunnelRoute: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json new file mode 100644 index 000000000000..40389ad0888f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json @@ -0,0 +1,63 @@ +{ + "name": "nextjs-16-tunnel", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": " next dev", + "build": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs", + "dev:webpack": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ next dev --webpack", + "build-webpack": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ next build --webpack > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "start": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ next start", + "lint": "eslint", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development _SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ playwright test", + "test:dev-webpack": "TEST_ENV=development-webpack _SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ playwright test", + "test:build": "pnpm install && pnpm build", + "test:build-webpack": "pnpm install && pnpm build-webpack", + "test:build-canary": "pnpm install && pnpm add next@canary && pnpm build", + "test:build-latest": "pnpm install && pnpm add next@latest && pnpm build", + "test:build-latest-webpack": "pnpm install && pnpm add next@latest && pnpm build-webpack", + "test:build-canary-webpack": "pnpm install && pnpm add next@canary && pnpm build-webpack", + "test:assert": "pnpm test:prod && pnpm test:dev", + "test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack" + }, + "dependencies": { + "@sentry/nextjs": "latest || *", + "@sentry/core": "latest || *", + "ai": "^3.0.0", + "import-in-the-middle": "^1", + "next": "16.0.0", + "react": "19.1.0", + "react-dom": "19.1.0", + "require-in-the-middle": "^7", + "zod": "^3.22.4" + }, + "devDependencies": { + "@playwright/test": "~1.53.2", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "canary", + "typescript": "^5" + }, + "volta": { + "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build-webpack", + "label": "nextjs-16-tunnel (webpack)", + "assert-command": "pnpm test:assert-webpack" + }, + { + "build-command": "pnpm test:build", + "label": "nextjs-16-tunnel (turbopack)", + "assert-command": "pnpm test:assert" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/playwright.config.mjs new file mode 100644 index 000000000000..797418b8cf7d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/playwright.config.mjs @@ -0,0 +1,29 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const getStartCommand = () => { + if (testEnv === 'development-webpack') { + return 'pnpm next dev -p 3030 --webpack 2>&1 | tee .tmp_dev_server_logs'; + } + + if (testEnv === 'development') { + return 'pnpm next dev -p 3030 2>&1 | tee .tmp_dev_server_logs'; + } + + if (testEnv === 'production') { + return 'pnpm next start -p 3030'; + } + + throw new Error(`Unknown test env: ${testEnv}`); +}; + +const config = getPlaywrightConfig({ + startCommand: getStartCommand(), + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/proxy.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/proxy.ts new file mode 100644 index 000000000000..28639f60bbe4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/proxy.ts @@ -0,0 +1,11 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export async function proxy(_request: NextRequest) { + return NextResponse.next(); +} + +// Match all routes to test that tunnel requests are properly filtered +export const config = { + matcher: '/:path*', +}; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/file.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/file.svg new file mode 100644 index 000000000000..004145cddf3f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/globe.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/globe.svg new file mode 100644 index 000000000000..567f17b0d7c7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/next.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/next.svg new file mode 100644 index 000000000000..5174b28c565c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/vercel.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/vercel.svg new file mode 100644 index 000000000000..77053960334e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/window.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/window.svg new file mode 100644 index 000000000000..b2b2a44f6ebc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts new file mode 100644 index 000000000000..8ba3a3bf2faa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + // Use a fake but properly formatted Sentry SaaS DSN for tunnel route testing + dsn: 'https://public@o12345.ingest.us.sentry.io/67890', + // No tunnel option - using tunnelRoute from withSentryConfig + tracesSampleRate: 1.0, + sendDefaultPii: true, + // debug: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts new file mode 100644 index 000000000000..8ba3a3bf2faa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + // Use a fake but properly formatted Sentry SaaS DSN for tunnel route testing + dsn: 'https://public@o12345.ingest.us.sentry.io/67890', + // No tunnel option - using tunnelRoute from withSentryConfig + tracesSampleRate: 1.0, + sendDefaultPii: true, + // debug: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/start-event-proxy.mjs new file mode 100644 index 000000000000..976073d3d2c4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/start-event-proxy.mjs @@ -0,0 +1,14 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'))); + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nextjs-16-tunnel', + envelopeDumpPath: path.join( + process.cwd(), + `event-dumps/nextjs-16-tunnel-v${packageJson.dependencies.next}-${process.env.TEST_ENV}.dump`, + ), +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts new file mode 100644 index 000000000000..a8bd7b4d925e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts @@ -0,0 +1,132 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Tunnel route should proxy pageload transaction to Sentry', async ({ page }) => { + // Wait for the pageload transaction to be sent through the tunnel + const pageloadTransactionPromise = waitForTransaction('nextjs-16-tunnel', async transactionEvent => { + return transactionEvent?.contexts?.trace?.op === 'pageload' && transactionEvent?.transaction === '/'; + }); + + // Navigate to the page + await page.goto('/'); + + const pageloadTransaction = await pageloadTransactionPromise; + + // Verify the pageload transaction was received successfully + expect(pageloadTransaction).toBeDefined(); + expect(pageloadTransaction.transaction).toBe('/'); + expect(pageloadTransaction.contexts?.trace?.op).toBe('pageload'); + expect(pageloadTransaction.contexts?.trace?.status).toBe('ok'); + expect(pageloadTransaction.type).toBe('transaction'); +}); + +test('Tunnel route should send multiple pageload transactions consistently', async ({ page }) => { + // This test verifies that the tunnel route remains consistent across multiple page loads + // (important for Turbopack which could generate different tunnel routes for client/server) + + // First pageload + const firstPageloadPromise = waitForTransaction('nextjs-16-tunnel', async transactionEvent => { + return transactionEvent?.contexts?.trace?.op === 'pageload' && transactionEvent?.transaction === '/'; + }); + + await page.goto('/'); + const firstPageload = await firstPageloadPromise; + + expect(firstPageload).toBeDefined(); + expect(firstPageload.transaction).toBe('/'); + expect(firstPageload.contexts?.trace?.op).toBe('pageload'); + expect(firstPageload.contexts?.trace?.status).toBe('ok'); + + // Second pageload (reload) + const secondPageloadPromise = waitForTransaction('nextjs-16-tunnel', async transactionEvent => { + return transactionEvent?.contexts?.trace?.op === 'pageload' && transactionEvent?.transaction === '/'; + }); + + await page.reload(); + const secondPageload = await secondPageloadPromise; + + expect(secondPageload).toBeDefined(); + expect(secondPageload.transaction).toBe('/'); + expect(secondPageload.contexts?.trace?.op).toBe('pageload'); + expect(secondPageload.contexts?.trace?.status).toBe('ok'); +}); + +test('Tunnel requests should not create middleware or fetch spans', async ({ page }) => { + // This test verifies that our span filtering logic works correctly + // The proxy runs on all routes, so we'll get a middleware transaction for `/` + // But we should NOT get middleware or fetch transactions for the tunnel route itself + + const allTransactions: any[] = []; + + // Collect all transactions + const collectPromise = (async () => { + // Keep collecting for 3 seconds after pageload + const endTime = Date.now() + 3000; + while (Date.now() < endTime) { + try { + const tx = await Promise.race([ + waitForTransaction('nextjs-16-tunnel', () => true), + new Promise((_, reject) => setTimeout(() => reject(), 500)), + ]); + allTransactions.push(tx); + } catch { + // Timeout, continue collecting + } + } + })(); + + // Wait for pageload transaction + const pageloadPromise = waitForTransaction('nextjs-16-tunnel', async transactionEvent => { + return transactionEvent?.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/'); + const pageloadTransaction = await pageloadPromise; + + // Trigger errors to force tunnel POST requests + await page + .evaluate(() => { + throw new Error('Test tunnel error 1'); + }) + .catch(() => { + // Expected to throw + }); + + await page + .evaluate(() => { + throw new Error('Test tunnel error 2'); + }) + .catch(() => { + // Expected to throw + }); + + // Wait for events to be sent through tunnel + await page.waitForTimeout(2000); + + // Continue collecting for a bit + await collectPromise; + + // We should have received the pageload transaction + expect(pageloadTransaction).toBeDefined(); + expect(pageloadTransaction.contexts?.trace?.op).toBe('pageload'); + + const middlewareTransactions = allTransactions.filter(tx => tx.contexts?.trace?.op === 'http.server.middleware'); + + // We WILL have a middleware transaction for GET / (the pageload) + // But we should NOT have middleware transactions for POST requests (tunnel route) + const postMiddlewareTransactions = middlewareTransactions.filter( + tx => tx.transaction?.includes('POST') || tx.contexts?.trace?.data?.['http.request.method'] === 'POST', + ); + + expect(postMiddlewareTransactions).toHaveLength(0); + + // We should NOT have any fetch transactions to Sentry ingest + const sentryFetchTransactions = allTransactions.filter( + tx => + tx.contexts?.trace?.op === 'http.client' && + (tx.contexts?.trace?.data?.['url.full']?.includes('sentry.io') || + tx.contexts?.trace?.data?.['url.full']?.includes('ingest')), + ); + + expect(sentryFetchTransactions).toHaveLength(0); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tsconfig.json new file mode 100644 index 000000000000..cc9ed39b5aa2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"], + "exclude": ["node_modules"] +} diff --git a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts new file mode 100644 index 000000000000..6f8b4eb96603 --- /dev/null +++ b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts @@ -0,0 +1,59 @@ +import { SEMATTRS_HTTP_TARGET } from '@opentelemetry/semantic-conventions'; +import { type Span, type SpanAttributes, GLOBAL_OBJ, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { isSentryRequestSpan } from '@sentry/opentelemetry'; +import { ATTR_NEXT_SPAN_TYPE } from '../nextSpanAttributes'; +import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../span-attributes-with-logic-attached'; + +const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryRewritesTunnelPath?: string; +}; + +/** + * Drops spans for tunnel requests from middleware or fetch instrumentation. + * This catches both: + * 1. Requests to the local tunnel route (before rewrite) + * 2. Requests to Sentry ingest (after rewrite) + */ +export function dropMiddlewareTunnelRequests(span: Span, attrs: SpanAttributes | undefined): void { + // Only filter middleware spans or HTTP fetch spans + const isMiddleware = attrs?.[ATTR_NEXT_SPAN_TYPE] === 'Middleware.execute'; + // The fetch span could be originating from rewrites re-writing a tunnel request + // So we want to filter it out + const isFetchSpan = attrs?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.http.otel.node_fetch'; + + // If the span is not a middleware span or a fetch span, return + if (!isMiddleware && !isFetchSpan) { + return; + } + + // Check if this is either a tunnel route request or a Sentry ingest request + const isTunnel = isTunnelRouteSpan(attrs || {}); + const isSentry = isSentryRequestSpan(span); + + if (isTunnel || isSentry) { + // Mark the span to be dropped + span.setAttribute(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true); + } +} + +/** + * Checks if a span's HTTP target matches the tunnel route. + */ +function isTunnelRouteSpan(spanAttributes: Record): boolean { + const tunnelPath = globalWithInjectedValues._sentryRewritesTunnelPath || process.env._sentryRewritesTunnelPath; + if (!tunnelPath) { + return false; + } + + // eslint-disable-next-line deprecation/deprecation + const httpTarget = spanAttributes[SEMATTRS_HTTP_TARGET]; + + if (typeof httpTarget === 'string') { + // Extract pathname from the target (e.g., "/tunnel?o=123&p=456" -> "/tunnel") + const pathname = httpTarget.split('?')[0] || ''; + + return pathname.startsWith(tunnelPath); + } + + return false; +} diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index e46d3f6bb5c7..b96b8e7f77ee 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -20,23 +20,31 @@ export function constructTurbopackConfig({ nextJsVersion, }: { userNextConfig: NextConfigObject; - userSentryOptions: SentryBuildOptions; + userSentryOptions?: SentryBuildOptions; routeManifest?: RouteManifest; nextJsVersion?: string; }): TurbopackOptions { // If sourcemaps are disabled, we don't need to enable native debug ids as this will add build time. const shouldEnableNativeDebugIds = (supportsNativeDebugIds(nextJsVersion ?? '') && userNextConfig?.turbopack?.debugIds) ?? - userSentryOptions.sourcemaps?.disable !== true; + userSentryOptions?.sourcemaps?.disable !== true; const newConfig: TurbopackOptions = { ...userNextConfig.turbopack, ...(shouldEnableNativeDebugIds ? { debugIds: true } : {}), }; + const tunnelPath = + userSentryOptions?.tunnelRoute !== undefined && + userNextConfig.output !== 'export' && + typeof userSentryOptions.tunnelRoute === 'string' + ? `${userNextConfig.basePath ?? ''}${userSentryOptions.tunnelRoute}` + : undefined; + const valueInjectionRules = generateValueInjectionRules({ routeManifest, nextJsVersion, + tunnelPath, }); for (const { matcher, rule } of valueInjectionRules) { diff --git a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts index 58cf7cdd0a15..2cf96b5f5ad7 100644 --- a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts +++ b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts @@ -8,9 +8,11 @@ import type { JSONValue, TurbopackMatcherWithRule } from '../types'; export function generateValueInjectionRules({ routeManifest, nextJsVersion, + tunnelPath, }: { routeManifest?: RouteManifest; nextJsVersion?: string; + tunnelPath?: string; }): TurbopackMatcherWithRule[] { const rules: TurbopackMatcherWithRule[] = []; const isomorphicValues: Record = {}; @@ -26,6 +28,11 @@ export function generateValueInjectionRules({ clientValues._sentryRouteManifest = JSON.stringify(routeManifest); } + // Inject tunnel route path for both client and server + if (tunnelPath) { + isomorphicValues._sentryRewritesTunnelPath = tunnelPath; + } + if (Object.keys(isomorphicValues).length > 0) { clientValues = { ...clientValues, ...isomorphicValues }; serverValues = { ...serverValues, ...isomorphicValues }; diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 7ac61d73aa73..892f4d6745fa 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -121,11 +121,10 @@ function getFinalConfigObject( ); } } else { - const resolvedTunnelRoute = - userSentryOptions.tunnelRoute === true ? generateRandomTunnelRoute() : userSentryOptions.tunnelRoute; - // Update the global options object to use the resolved value everywhere + const resolvedTunnelRoute = resolveTunnelRoute(userSentryOptions.tunnelRoute); userSentryOptions.tunnelRoute = resolvedTunnelRoute || undefined; + setUpTunnelRewriteRules(incomingUserNextConfigObject, resolvedTunnelRoute); } } @@ -392,6 +391,13 @@ function getFinalConfigObject( */ function setUpTunnelRewriteRules(userNextConfig: NextConfigObject, tunnelPath: string): void { const originalRewrites = userNextConfig.rewrites; + // Allow overriding the tunnel destination for E2E tests via environment variable + const destinationOverride = process.env._SENTRY_TUNNEL_DESTINATION_OVERRIDE; + + // Make sure destinations are statically defined at build time + const destination = destinationOverride || 'https://o:orgid.ingest.sentry.io/api/:projectid/envelope/?hsts=0'; + const destinationWithRegion = + destinationOverride || 'https://o:orgid.ingest.:region.sentry.io/api/:projectid/envelope/?hsts=0'; // This function doesn't take any arguments at the time of writing but we future-proof // here in case Next.js ever decides to pass some @@ -412,7 +418,7 @@ function setUpTunnelRewriteRules(userNextConfig: NextConfigObject, tunnelPath: s value: '(? \\d*)', }, ], - destination: 'https://o:orgid.ingest.sentry.io/api/:projectid/envelope/?hsts=0', + destination, }; const tunnelRouteRewriteWithRegion = { @@ -436,7 +442,7 @@ function setUpTunnelRewriteRules(userNextConfig: NextConfigObject, tunnelPath: s value: '(? [a-z]{2})', }, ], - destination: 'https://o:orgid.ingest.:region.sentry.io/api/:projectid/envelope/?hsts=0', + destination: destinationWithRegion, }; // Order of these is important, they get applied first to last. @@ -550,3 +556,26 @@ function getInstrumentationClientFileContents(): string | void { } } } + +/** + * Resolves the tunnel route based on the user's configuration and the environment. + * @param tunnelRoute - The user-provided tunnel route option + */ +function resolveTunnelRoute(tunnelRoute: string | true): string { + if (process.env.__SENTRY_TUNNEL_ROUTE__) { + // Reuse cached value from previous build (server/client) + return process.env.__SENTRY_TUNNEL_ROUTE__; + } + + const resolvedTunnelRoute = typeof tunnelRoute === 'string' ? tunnelRoute : generateRandomTunnelRoute(); + + // Cache for subsequent builds (only during build time) + // Turbopack runs the config twice, so we need a shared context to avoid generating a new tunnel route for each build. + // env works well here + // https://linear.app/getsentry/issue/JS-549/adblock-plus-blocking-requests-to-sentry-and-monitoring-tunnel + if (resolvedTunnelRoute) { + process.env.__SENTRY_TUNNEL_ROUTE__ = resolvedTunnelRoute; + } + + return resolvedTunnelRoute; +} diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 5fd92707b912..091adab98dee 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -1,5 +1,6 @@ import { context } from '@opentelemetry/api'; import { + type EventProcessor, applySdkMetadata, getCapturedScopesOnSpan, getCurrentScope, @@ -19,7 +20,9 @@ import { import { getScopesFromContext } from '@sentry/opentelemetry'; import type { VercelEdgeOptions } from '@sentry/vercel-edge'; import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; +import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../common/span-attributes-with-logic-attached'; import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; +import { dropMiddlewareTunnelRequests } from '../common/utils/dropMiddlewareTunnelRequests'; import { isBuild } from '../common/utils/isBuild'; import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; @@ -35,6 +38,7 @@ export type EdgeOptions = VercelEdgeOptions; const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewriteFramesDistDir?: string; _sentryRelease?: string; + _sentryRewritesTunnelPath?: string; }; /** Inits the Sentry NextJS SDK on the Edge Runtime. */ @@ -70,6 +74,8 @@ export function init(options: VercelEdgeOptions = {}): void { const rootSpan = getRootSpan(span); const isRootSpan = span === rootSpan; + dropMiddlewareTunnelRequests(span, spanAttributes); + // Mark all spans generated by Next.js as 'auto' if (spanAttributes?.['next.span_type'] !== undefined) { span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto'); @@ -137,6 +143,24 @@ export function init(options: VercelEdgeOptions = {}): void { } }); + getGlobalScope().addEventProcessor( + Object.assign( + (event => { + // Filter transactions that we explicitly want to drop. + if (event.type === 'transaction') { + if (event.contexts?.trace?.data?.[TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]) { + return null; + } + + return event; + } else { + return event; + } + }) satisfies EventProcessor, + { id: 'NextLowQualityTransactionsFilter' }, + ), + ); + try { // @ts-expect-error `process.turbopack` is a magic string that will be replaced by Next.js if (process.turbopack) { diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index ce8ac7c56cea..caec9a9f1af1 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -38,6 +38,7 @@ import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, } from '../common/span-attributes-with-logic-attached'; import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; +import { dropMiddlewareTunnelRequests } from '../common/utils/dropMiddlewareTunnelRequests'; import { isBuild } from '../common/utils/isBuild'; import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; @@ -169,6 +170,8 @@ export function init(options: NodeOptions): NodeClient | undefined { const rootSpan = getRootSpan(span); const isRootSpan = span === rootSpan; + dropMiddlewareTunnelRequests(span, spanAttributes); + // What we do in this glorious piece of code, is hoist any information about parameterized routes from spans emitted // by Next.js via the `next.route` attribute, up to the transaction by setting the http.route attribute. if (typeof spanAttributes?.[ATTR_NEXT_ROUTE] === 'string') { From 235c8651356fb66b6214ff5b88f16419c65f3478 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 24 Nov 2025 17:18:17 +0100 Subject: [PATCH 30/32] feat(core): Re-add `_experiments.enableLogs` option (#18299) We're re-introducing `_experiments.enableLogs`. The option stays deprecated and maybe we can actually remove it or type it as `undefined` in the next major to sunset it for good. Main motivation for re-adding: The flag was introduced in v9 while we already worked on v10 where we removed it again. Therefore, it had an unusually short lifespan. Some users didn't realize this when upgrading to v10 and were wondering where their logs went. --- .../suites/public-api/logger/init.js | 5 +++- .../public-api/logger/integration/init.js | 5 ++++ .../suites/winston/subject.ts | 6 +++- packages/core/src/client.ts | 5 ++++ packages/core/src/types-hoist/options.ts | 8 +++++ packages/core/test/lib/client.test.ts | 30 +++++++++++++++++++ 6 files changed, 57 insertions(+), 2 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/init.js b/dev-packages/browser-integration-tests/suites/public-api/logger/init.js index 8026df91ea46..1fa010f49659 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/init.js +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/init.js @@ -4,5 +4,8 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - enableLogs: true, + // purposefully testing against the experimental flag here + _experiments: { + enableLogs: true, + }, }); diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/init.js b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/init.js index 809b78739e77..e26b03d7fc61 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/init.js +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/init.js @@ -5,5 +5,10 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', enableLogs: true, + // Purposefully specifying the experimental flag here + // to ensure the top level option is used instead. + _experiments: { + enableLogs: false, + }, integrations: [Sentry.consoleLoggingIntegration()], }); diff --git a/dev-packages/node-core-integration-tests/suites/winston/subject.ts b/dev-packages/node-core-integration-tests/suites/winston/subject.ts index 3c31ddb63fa5..02ffcdb0f5cb 100644 --- a/dev-packages/node-core-integration-tests/suites/winston/subject.ts +++ b/dev-packages/node-core-integration-tests/suites/winston/subject.ts @@ -8,7 +8,11 @@ const client = Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0.0', environment: 'test', - enableLogs: true, + // Purposefully specifying the experimental flag here + // to ensure the top level option is still respected. + _experiments: { + enableLogs: true, + }, transport: loggingTransport, }); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index ef05750009c3..805d8e596528 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -239,6 +239,11 @@ export abstract class Client { }); } + // Backfill enableLogs option from _experiments.enableLogs + // TODO(v11): Remove or change default value + // eslint-disable-next-line deprecation/deprecation + this._options.enableLogs = this._options.enableLogs ?? this._options._experiments?.enableLogs; + // Setup log flushing with weight and timeout tracking if (this._options.enableLogs) { setupWeightBasedFlushing(this, 'afterCaptureLog', 'flushLogs', estimateLogSizeInBytes, _INTERNAL_flushLogsBuffer); diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index 59c4609f01c4..c33d0107df5f 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -306,6 +306,14 @@ export interface ClientOptions Metric | null; + + /** + * Determines if logs support should be enabled. + * + * @default false + * @deprecated Use the top level `enableLogs` option instead. + */ + enableLogs?: boolean; }; /** diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index 2a2d77171880..a59a8bbb8780 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -2734,6 +2734,36 @@ describe('Client', () => { }); }); + describe('enableLogs', () => { + it('defaults to `undefined`', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); + const client = new TestClient(options); + expect(client.getOptions().enableLogs).toBeUndefined(); + }); + + it('can be set as a top-level option', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const client = new TestClient(options); + expect(client.getOptions().enableLogs).toBe(true); + }); + + it('can be set as an experimental option', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } }); + const client = new TestClient(options); + expect(client.getOptions().enableLogs).toBe(true); + }); + + test('top-level option takes precedence over experimental option', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + enableLogs: true, + _experiments: { enableLogs: false }, + }); + const client = new TestClient(options); + expect(client.getOptions().enableLogs).toBe(true); + }); + }); + describe('log weight-based flushing', () => { beforeEach(() => { vi.useFakeTimers(); From 6ce620e983814a263eb036a3ee79f80e780a880a Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 24 Nov 2025 18:31:48 +0100 Subject: [PATCH 31/32] fix(core): Always redact content of sensitive headers regardless of `sendDefaultPii` (#18311) In case an HTTP header is considered "sensitive" (could contain tokens), the value is already filtered within the SDK. --- Follow-up on this PR: - https://github.com/getsentry/sentry-javascript/pull/17475 --- packages/astro/src/server/middleware.ts | 5 +- packages/bun/src/integrations/bunserver.ts | 5 +- packages/cloudflare/src/request.ts | 3 +- packages/core/src/utils/request.ts | 39 ++++++---- packages/core/test/lib/utils/request.test.ts | 76 ++++++++----------- .../common/utils/addHeadersAsAttributes.ts | 7 +- .../http/httpServerSpansIntegration.ts | 3 +- .../runtime/hooks/wrapMiddlewareHandler.ts | 7 +- packages/remix/src/server/instrumentServer.ts | 5 +- .../sveltekit/src/server-common/handle.ts | 11 +-- 10 files changed, 68 insertions(+), 93 deletions(-) diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index a12c25ff6045..64fde266a3f8 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -219,10 +219,7 @@ async function instrumentRequestStartHttpServerSpan( // This is here for backwards compatibility, we used to set this here before method, url: stripUrlQueryAndFragment(ctx.url.href), - ...httpHeadersToSpanAttributes( - winterCGHeadersToDict(request.headers), - getClient()?.getOptions().sendDefaultPii ?? false, - ), + ...httpHeadersToSpanAttributes(winterCGHeadersToDict(request.headers)), }; if (parametrizedRoute) { diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index 4a079f488474..73998e529349 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -3,7 +3,6 @@ import { captureException, continueTrace, defineIntegration, - getClient, httpHeadersToSpanAttributes, isURLObjectRelative, parseStringToURLObject, @@ -207,9 +206,7 @@ function wrapRequestHandler ( routeName = route; } - const client = getClient(); - const sendDefaultPii = client?.getOptions().sendDefaultPii ?? false; - Object.assign(attributes, httpHeadersToSpanAttributes(request.headers.toJSON(), sendDefaultPii)); + Object.assign(attributes, httpHeadersToSpanAttributes(request.headers.toJSON())); isolationScope.setSDKProcessingMetadata({ normalizedRequest: { diff --git a/packages/cloudflare/src/request.ts b/packages/cloudflare/src/request.ts index 5c97562d9fde..7908d3dcf48e 100644 --- a/packages/cloudflare/src/request.ts +++ b/packages/cloudflare/src/request.ts @@ -66,8 +66,7 @@ export function wrapRequestHandler( attributes['user_agent.original'] = userAgentHeader; } - const sendDefaultPii = options.sendDefaultPii ?? false; - Object.assign(attributes, httpHeadersToSpanAttributes(winterCGHeadersToDict(request.headers), sendDefaultPii)); + Object.assign(attributes, httpHeadersToSpanAttributes(winterCGHeadersToDict(request.headers))); attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = 'http.server'; diff --git a/packages/core/src/utils/request.ts b/packages/core/src/utils/request.ts index ffd60f3e8486..1d3985dd8479 100644 --- a/packages/core/src/utils/request.ts +++ b/packages/core/src/utils/request.ts @@ -129,7 +129,19 @@ function getAbsoluteUrl({ } // "-user" because otherwise it would match "user-agent" -const SENSITIVE_HEADER_SNIPPETS = ['auth', 'token', 'secret', 'cookie', '-user', 'password', 'key']; +const SENSITIVE_HEADER_SNIPPETS = [ + 'auth', + 'token', + 'secret', + 'cookie', + '-user', + 'password', + 'key', + 'jwt', + 'bearer', + 'sso', + 'saml', +]; /** * Converts incoming HTTP request headers to OpenTelemetry span attributes following semantic conventions. @@ -140,26 +152,25 @@ const SENSITIVE_HEADER_SNIPPETS = ['auth', 'token', 'secret', 'cookie', '-user', */ export function httpHeadersToSpanAttributes( headers: Record , - sendDefaultPii: boolean = false, ): Record { const spanAttributes: Record = {}; try { Object.entries(headers).forEach(([key, value]) => { - if (value !== undefined) { - const lowerCasedKey = key.toLowerCase(); - - if (!sendDefaultPii && SENSITIVE_HEADER_SNIPPETS.some(snippet => lowerCasedKey.includes(snippet))) { - return; - } + if (value == null) { + return; + } - const normalizedKey = `http.request.header.${lowerCasedKey.replace(/-/g, '_')}`; + const lowerCasedKey = key.toLowerCase(); + const isSensitive = SENSITIVE_HEADER_SNIPPETS.some(snippet => lowerCasedKey.includes(snippet)); + const normalizedKey = `http.request.header.${lowerCasedKey.replace(/-/g, '_')}`; - if (Array.isArray(value)) { - spanAttributes[normalizedKey] = value.map(v => (v !== null && v !== undefined ? String(v) : v)).join(';'); - } else if (typeof value === 'string') { - spanAttributes[normalizedKey] = value; - } + if (isSensitive) { + spanAttributes[normalizedKey] = '[Filtered]'; + } else if (Array.isArray(value)) { + spanAttributes[normalizedKey] = value.map(v => (v != null ? String(v) : v)).join(';'); + } else if (typeof value === 'string') { + spanAttributes[normalizedKey] = value; } }); } catch { diff --git a/packages/core/test/lib/utils/request.test.ts b/packages/core/test/lib/utils/request.test.ts index b37ee860f43f..328aebf29209 100644 --- a/packages/core/test/lib/utils/request.test.ts +++ b/packages/core/test/lib/utils/request.test.ts @@ -613,61 +613,25 @@ describe('request utils', () => { }); describe('PII filtering', () => { - it('filters out sensitive headers when sendDefaultPii is false (default)', () => { - const headers = { - 'Content-Type': 'application/json', - 'User-Agent': 'test-agent', - Authorization: 'Bearer secret-token', - Cookie: 'session=abc123', - 'X-API-Key': 'api-key-123', - 'X-Auth-Token': 'auth-token-456', - }; - - const result = httpHeadersToSpanAttributes(headers, false); - - expect(result).toEqual({ - 'http.request.header.content_type': 'application/json', - 'http.request.header.user_agent': 'test-agent', - // Sensitive headers should be filtered out - }); - }); - - it('includes sensitive headers when sendDefaultPii is true', () => { - const headers = { - 'Content-Type': 'application/json', - 'User-Agent': 'test-agent', - Authorization: 'Bearer secret-token', - Cookie: 'session=abc123', - 'X-API-Key': 'api-key-123', - }; - - const result = httpHeadersToSpanAttributes(headers, true); - - expect(result).toEqual({ - 'http.request.header.content_type': 'application/json', - 'http.request.header.user_agent': 'test-agent', - 'http.request.header.authorization': 'Bearer secret-token', - 'http.request.header.cookie': 'session=abc123', - 'http.request.header.x_api_key': 'api-key-123', - }); - }); - it('filters sensitive headers case-insensitively', () => { const headers = { AUTHORIZATION: 'Bearer secret-token', Cookie: 'session=abc123', - 'x-api-key': 'key-123', + 'x-aPi-kEy': 'key-123', 'Content-Type': 'application/json', }; - const result = httpHeadersToSpanAttributes(headers, false); + const result = httpHeadersToSpanAttributes(headers); expect(result).toEqual({ 'http.request.header.content_type': 'application/json', + 'http.request.header.cookie': '[Filtered]', + 'http.request.header.x_api_key': '[Filtered]', + 'http.request.header.authorization': '[Filtered]', }); }); - it('filters comprehensive list of sensitive headers', () => { + it('always filters comprehensive list of sensitive headers', () => { const headers = { 'Content-Type': 'application/json', 'User-Agent': 'test-agent', @@ -692,15 +656,41 @@ describe('request utils', () => { 'X-Private-Key': 'private', 'X-Forwarded-user': 'user', 'X-Forwarded-authorization': 'auth', + 'x-jwt-token': 'jwt', + 'x-bearer-token': 'bearer', + 'x-sso-token': 'sso', + 'x-saml-token': 'saml', }; - const result = httpHeadersToSpanAttributes(headers, false); + const result = httpHeadersToSpanAttributes(headers); + // Sensitive headers are always included and redacted expect(result).toEqual({ 'http.request.header.content_type': 'application/json', 'http.request.header.user_agent': 'test-agent', 'http.request.header.accept': 'application/json', 'http.request.header.host': 'example.com', + 'http.request.header.authorization': '[Filtered]', + 'http.request.header.cookie': '[Filtered]', + 'http.request.header.set_cookie': '[Filtered]', + 'http.request.header.x_api_key': '[Filtered]', + 'http.request.header.x_auth_token': '[Filtered]', + 'http.request.header.x_secret': '[Filtered]', + 'http.request.header.x_secret_key': '[Filtered]', + 'http.request.header.www_authenticate': '[Filtered]', + 'http.request.header.proxy_authorization': '[Filtered]', + 'http.request.header.x_access_token': '[Filtered]', + 'http.request.header.x_csrf_token': '[Filtered]', + 'http.request.header.x_xsrf_token': '[Filtered]', + 'http.request.header.x_session_token': '[Filtered]', + 'http.request.header.x_password': '[Filtered]', + 'http.request.header.x_private_key': '[Filtered]', + 'http.request.header.x_forwarded_user': '[Filtered]', + 'http.request.header.x_forwarded_authorization': '[Filtered]', + 'http.request.header.x_jwt_token': '[Filtered]', + 'http.request.header.x_bearer_token': '[Filtered]', + 'http.request.header.x_sso_token': '[Filtered]', + 'http.request.header.x_saml_token': '[Filtered]', }); }); }); diff --git a/packages/nextjs/src/common/utils/addHeadersAsAttributes.ts b/packages/nextjs/src/common/utils/addHeadersAsAttributes.ts index 4e8cdb3fe7c9..ff025fc3ecc7 100644 --- a/packages/nextjs/src/common/utils/addHeadersAsAttributes.ts +++ b/packages/nextjs/src/common/utils/addHeadersAsAttributes.ts @@ -1,5 +1,5 @@ import type { Span, WebFetchHeaders } from '@sentry/core'; -import { getClient, httpHeadersToSpanAttributes, winterCGHeadersToDict } from '@sentry/core'; +import { httpHeadersToSpanAttributes, winterCGHeadersToDict } from '@sentry/core'; /** * Extracts HTTP request headers as span attributes and optionally applies them to a span. @@ -12,15 +12,12 @@ export function addHeadersAsAttributes( return {}; } - const client = getClient(); - const sendDefaultPii = client?.getOptions().sendDefaultPii ?? false; - const headersDict: Record = headers instanceof Headers || (typeof headers === 'object' && 'get' in headers) ? winterCGHeadersToDict(headers as Headers) : headers; - const headerAttributes = httpHeadersToSpanAttributes(headersDict, sendDefaultPii); + const headerAttributes = httpHeadersToSpanAttributes(headersDict); if (span) { span.setAttributes(headerAttributes); diff --git a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts index c24c0c68d1da..34741e95c912 100644 --- a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts +++ b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts @@ -136,7 +136,6 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions const method = normalizedRequest.method || request.method?.toUpperCase() || 'GET'; const httpTargetWithoutQueryFragment = urlObj ? urlObj.pathname : stripUrlQueryAndFragment(fullUrl); const bestEffortTransactionName = `${method} ${httpTargetWithoutQueryFragment}`; - const shouldSendDefaultPii = client.getOptions().sendDefaultPii ?? false; // We use the plain tracer.startSpan here so we can pass the span kind const span = tracer.startSpan(bestEffortTransactionName, { @@ -158,7 +157,7 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions 'http.flavor': httpVersion, 'net.transport': httpVersion?.toUpperCase() === 'QUIC' ? 'ip_udp' : 'ip_tcp', ...getRequestContentLengthAttribute(request), - ...httpHeadersToSpanAttributes(normalizedRequest.headers || {}, shouldSendDefaultPii), + ...httpHeadersToSpanAttributes(normalizedRequest.headers || {}), }, }); diff --git a/packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts b/packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts index a04b866cd774..4b41d6e8ab82 100644 --- a/packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts +++ b/packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts @@ -3,7 +3,6 @@ import { captureException, debug, flushIfServerless, - getClient, httpHeadersToSpanAttributes, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -171,13 +170,9 @@ function getSpanAttributes( attributes['http.route'] = event.path; } - // Extract and add HTTP headers as span attributes - const client = getClient(); - const sendDefaultPii = client?.getOptions().sendDefaultPii ?? false; - // Get headers from the Node.js request object const headers = event.node?.req?.headers || {}; - const headerAttributes = httpHeadersToSpanAttributes(headers, sendDefaultPii); + const headerAttributes = httpHeadersToSpanAttributes(headers); // Merge header attributes with existing attributes Object.assign(attributes, headerAttributes); diff --git a/packages/remix/src/server/instrumentServer.ts b/packages/remix/src/server/instrumentServer.ts index fda9b3f10b75..2416699cb2a6 100644 --- a/packages/remix/src/server/instrumentServer.ts +++ b/packages/remix/src/server/instrumentServer.ts @@ -310,10 +310,7 @@ function wrapRequestHandler ServerBuild | Promise [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', method: request.method, - ...httpHeadersToSpanAttributes( - winterCGHeadersToDict(request.headers), - clientOptions.sendDefaultPii ?? false, - ), + ...httpHeadersToSpanAttributes(winterCGHeadersToDict(request.headers)), }, }, async span => { diff --git a/packages/sveltekit/src/server-common/handle.ts b/packages/sveltekit/src/server-common/handle.ts index 26872a0f6f24..3d9963bd1056 100644 --- a/packages/sveltekit/src/server-common/handle.ts +++ b/packages/sveltekit/src/server-common/handle.ts @@ -3,7 +3,6 @@ import { continueTrace, debug, flushIfServerless, - getClient, getCurrentScope, getDefaultIsolationScope, getIsolationScope, @@ -179,10 +178,7 @@ async function instrumentHandle( [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeName ? 'route' : 'url', 'sveltekit.tracing.original_name': originalName, - ...httpHeadersToSpanAttributes( - winterCGHeadersToDict(event.request.headers), - getClient()?.getOptions().sendDefaultPii ?? false, - ), + ...httpHeadersToSpanAttributes(winterCGHeadersToDict(event.request.headers)), }); } @@ -208,10 +204,7 @@ async function instrumentHandle( [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeId ? 'route' : 'url', 'http.method': event.request.method, - ...httpHeadersToSpanAttributes( - winterCGHeadersToDict(event.request.headers), - getClient()?.getOptions().sendDefaultPii ?? false, - ), + ...httpHeadersToSpanAttributes(winterCGHeadersToDict(event.request.headers)), }, name: routeName, }, From 02aa2ea072fa956c805eeb6f463fb6ed763efa57 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 24 Nov 2025 18:45:17 +0100 Subject: [PATCH 32/32] meta(changelog): Update changelog for 10.27.0 --- CHANGELOG.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bee97ac9189..58e2cf7bd830 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,11 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -Work in this release was contributed by @bignoncedric and @adam-kov. Thank you for your contributions! +## 10.27.0 + +### Important Changes -- feat(deps): Bump OpenTelemetry ([#18239](https://github.com/getsentry/sentry-javascript/pull/18239)) +- **feat(deps): Bump OpenTelemetry ([#18239](https://github.com/getsentry/sentry-javascript/pull/18239))** - Bump @opentelemetry/context-async-hooks from ^2.1.0 to ^2.2.0 - Bump @opentelemetry/core from ^2.1.0 to ^2.2.0 - Bump @opentelemetry/resources from ^2.1.0 to ^2.2.0 @@ -39,6 +41,49 @@ Work in this release was contributed by @bignoncedric and @adam-kov. Thank you f - Bump @opentelemetry/instrumentation-undici from 0.15.0 to 0.19.0 - Bump @prisma/instrumentation from 6.15.0 to 6.19.0 +- **feat(browserprofiling): Add `manual` mode and deprecate old profiling ([#18189](https://github.com/getsentry/sentry-javascript/pull/18189))** + + Adds the `manual` lifecycle mode for UI profiling (the default mode), allowing profiles to be captured manually with `Sentry.uiProfiler.startProfiler()` and `Sentry.uiProfiler.stopProfiler()`. + The previous transaction-based profiling is with `profilesSampleRate` is now deprecated in favor of the new UI Profiling with `profileSessionSampleRate`. + +### Other Changes + +- feat(core): Add `gibibyte` and `pebibyte` to `InformationUnit` type ([#18241](https://github.com/getsentry/sentry-javascript/pull/18241)) +- feat(core): Add scope attribute APIs ([#18165](https://github.com/getsentry/sentry-javascript/pull/18165)) +- feat(core): Re-add `_experiments.enableLogs` option ([#18299](https://github.com/getsentry/sentry-javascript/pull/18299)) +- feat(core): Use `maxValueLength` on error messages ([#18301](https://github.com/getsentry/sentry-javascript/pull/18301)) +- feat(deps): bump @sentry/bundler-plugin-core from 4.3.0 to 4.6.1 ([#18273](https://github.com/getsentry/sentry-javascript/pull/18273)) +- feat(deps): bump @sentry/cli from 2.56.0 to 2.58.2 ([#18271](https://github.com/getsentry/sentry-javascript/pull/18271)) +- feat(node): Add tracing support for AzureOpenAI ([#18281](https://github.com/getsentry/sentry-javascript/pull/18281)) +- feat(node): Fix local variables capturing for out-of-app frames ([#18245](https://github.com/getsentry/sentry-javascript/pull/18245)) +- fix(core): Add a PromiseBuffer for incoming events on the client ([#18120](https://github.com/getsentry/sentry-javascript/pull/18120)) +- fix(core): Always redact content of sensitive headers regardless of `sendDefaultPii` ([#18311](https://github.com/getsentry/sentry-javascript/pull/18311)) +- fix(metrics): Update return type of `beforeSendMetric` ([#18261](https://github.com/getsentry/sentry-javascript/pull/18261)) +- fix(nextjs): universal random tunnel path support ([#18257](https://github.com/getsentry/sentry-javascript/pull/18257)) +- ref(react): Add more guarding against wildcards in lazy route transactions ([#18155](https://github.com/getsentry/sentry-javascript/pull/18155)) +- chore(deps): bump glob from 11.0.1 to 11.1.0 in /packages/react-router ([#18243](https://github.com/getsentry/sentry-javascript/pull/18243)) + + ++ +Work in this release was contributed by @bignoncedric and @adam-kov. Thank you for your contributions! + ## 10.26.0 ### Important ChangesInternal Changes
+ - build(deps): bump hono from 4.9.7 to 4.10.3 in /dev-packages/e2e-tests/test-applications/cloudflare-hono ([#18038](https://github.com/getsentry/sentry-javascript/pull/18038)) + - chore: Add `bump_otel_instrumentations` cursor command ([#18253](https://github.com/getsentry/sentry-javascript/pull/18253)) + - chore: Add external contributor to CHANGELOG.md ([#18297](https://github.com/getsentry/sentry-javascript/pull/18297)) + - chore: Add external contributor to CHANGELOG.md ([#18300](https://github.com/getsentry/sentry-javascript/pull/18300)) + - chore: Do not update opentelemetry ([#18254](https://github.com/getsentry/sentry-javascript/pull/18254)) + - chore(angular): Add Angular 21 Support ([#18274](https://github.com/getsentry/sentry-javascript/pull/18274)) + - chore(deps): bump astro from 4.16.18 to 5.15.9 in /dev-packages/e2e-tests/test-applications/cloudflare-astro ([#18259](https://github.com/getsentry/sentry-javascript/pull/18259)) + - chore(dev-deps): Update some dev dependencies ([#17816](https://github.com/getsentry/sentry-javascript/pull/17816)) + - ci(deps): Bump actions/create-github-app-token from 2.1.1 to 2.1.4 ([#17825](https://github.com/getsentry/sentry-javascript/pull/17825)) + - ci(deps): bump actions/setup-node from 4 to 6 ([#18077](https://github.com/getsentry/sentry-javascript/pull/18077)) + - ci(deps): bump actions/upload-artifact from 4 to 5 ([#18075](https://github.com/getsentry/sentry-javascript/pull/18075)) + - ci(deps): bump github/codeql-action from 3 to 4 ([#18076](https://github.com/getsentry/sentry-javascript/pull/18076)) + - doc(sveltekit): Update documentation link for SvelteKit guide ([#18298](https://github.com/getsentry/sentry-javascript/pull/18298)) + - test(e2e): Fix astro config in test app ([#18282](https://github.com/getsentry/sentry-javascript/pull/18282)) + - test(nextjs): Remove debug logs from e2e test ([#18250](https://github.com/getsentry/sentry-javascript/pull/18250)) +