From 86f5dc834a430461e4743f0cd6373cf86215bba2 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 19 Mar 2026 14:02:56 +0100 Subject: [PATCH 01/43] fix(serverless): Add node to metadata (#19878) We are not passing the underlying `node` metadata for our `aws-serverless` and `google-cloud-serverless` packages. Closes #19879 (added automatically) --- packages/aws-serverless/src/init.ts | 2 +- packages/aws-serverless/test/sdk.test.ts | 4 ++++ packages/google-cloud-serverless/src/sdk.ts | 2 +- packages/google-cloud-serverless/test/sdk.test.ts | 4 ++++ 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/aws-serverless/src/init.ts b/packages/aws-serverless/src/init.ts index 25180a41f6e6..fe069d7ff6f5 100644 --- a/packages/aws-serverless/src/init.ts +++ b/packages/aws-serverless/src/init.ts @@ -109,7 +109,7 @@ export function init(options: AwsServerlessOptions = {}): NodeClient | undefined } } - applySdkMetadata(opts, 'aws-serverless', ['aws-serverless'], sdkSource); + applySdkMetadata(opts, 'aws-serverless', ['aws-serverless', 'node'], sdkSource); return initWithoutDefaultIntegrations(opts); } diff --git a/packages/aws-serverless/test/sdk.test.ts b/packages/aws-serverless/test/sdk.test.ts index c0f508eb3859..aa16049e2e2b 100644 --- a/packages/aws-serverless/test/sdk.test.ts +++ b/packages/aws-serverless/test/sdk.test.ts @@ -618,6 +618,10 @@ describe('AWSLambda', () => { name: 'npm:@sentry/aws-serverless', version: expect.any(String), }, + { + name: 'npm:@sentry/node', + version: expect.any(String), + }, ], version: expect.any(String), }, diff --git a/packages/google-cloud-serverless/src/sdk.ts b/packages/google-cloud-serverless/src/sdk.ts index 1161ab60300e..6eb80aed2f64 100644 --- a/packages/google-cloud-serverless/src/sdk.ts +++ b/packages/google-cloud-serverless/src/sdk.ts @@ -29,7 +29,7 @@ export function init(options: NodeOptions = {}): NodeClient | undefined { ...options, }; - applySdkMetadata(opts, 'google-cloud-serverless'); + applySdkMetadata(opts, 'google-cloud-serverless', ['google-cloud-serverless', 'node']); return initNode(opts); } diff --git a/packages/google-cloud-serverless/test/sdk.test.ts b/packages/google-cloud-serverless/test/sdk.test.ts index 5b41bc4abb21..553911ead646 100644 --- a/packages/google-cloud-serverless/test/sdk.test.ts +++ b/packages/google-cloud-serverless/test/sdk.test.ts @@ -31,6 +31,10 @@ describe('init()', () => { name: 'npm:@sentry/google-cloud-serverless', version: expect.any(String), }, + { + name: 'npm:@sentry/node', + version: expect.any(String), + }, ], version: expect.any(String), }, From 399df7c54c98890b16c06bc7373db3bfb5474249 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 19 Mar 2026 16:16:37 +0100 Subject: [PATCH 02/43] test(astro): Re-enable server island tracing e2e test in Astro 6 (#19872) https://github.com/withastro/astro/issues/15753 was fixed, so our server islands e2e test should work again in Astro 6. This PR bumps the Astro version to the most recent one and re-enables the test --------- Co-authored-by: Charly Gomez --- .../e2e-tests/test-applications/astro-6/package.json | 2 +- .../astro-6/tests/tracing.serverIslands.test.ts | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/astro-6/package.json b/dev-packages/e2e-tests/test-applications/astro-6/package.json index e97314a949b4..050ea8980e06 100644 --- a/dev-packages/e2e-tests/test-applications/astro-6/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-6/package.json @@ -16,7 +16,7 @@ "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", "@sentry/astro": "latest || *", - "astro": "^6.0.0" + "astro": "^6.0.6" }, "volta": { "node": "22.22.0", diff --git a/dev-packages/e2e-tests/test-applications/astro-6/tests/tracing.serverIslands.test.ts b/dev-packages/e2e-tests/test-applications/astro-6/tests/tracing.serverIslands.test.ts index 10910c01bd3f..829b44a035f3 100644 --- a/dev-packages/e2e-tests/test-applications/astro-6/tests/tracing.serverIslands.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-6/tests/tracing.serverIslands.test.ts @@ -1,10 +1,7 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; -// Skipping this test FOR NOW because there's a known bug in Astro 6.0.2 that causes -// server-islands to not work correctly with the node adapter: -// https://github.com/withastro/astro/issues/15753 -test.describe.skip('tracing in static routes with server islands', () => { +test.describe('tracing in static routes with server islands', () => { test('only sends client pageload transaction and server island endpoint transaction', async ({ page }) => { const clientPageloadTxnPromise = waitForTransaction('astro-6', txnEvent => { return txnEvent.transaction === '/server-island'; From 8d14dea1d57f25eae48fd0e28d71f75c0a0dc44f Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 19 Mar 2026 17:18:52 +0100 Subject: [PATCH 03/43] fix(nestjs): Add `node` to nest metadata (#19875) Just saw that we missed to pass the underlying node sdk metadata. Closes #19876 (added automatically) --- packages/nestjs/src/sdk.ts | 2 +- packages/nestjs/test/sdk.test.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/nestjs/src/sdk.ts b/packages/nestjs/src/sdk.ts index 733cb935003c..f72735af45e7 100644 --- a/packages/nestjs/src/sdk.ts +++ b/packages/nestjs/src/sdk.ts @@ -18,7 +18,7 @@ export function init(options: NodeOptions | undefined = {}): NodeClient | undefi ...options, }; - applySdkMetadata(opts, 'nestjs'); + applySdkMetadata(opts, 'nestjs', ['nestjs', 'node']); const client = nodeInit(opts); diff --git a/packages/nestjs/test/sdk.test.ts b/packages/nestjs/test/sdk.test.ts index 1692c9be6fdd..5def451d6415 100644 --- a/packages/nestjs/test/sdk.test.ts +++ b/packages/nestjs/test/sdk.test.ts @@ -20,7 +20,10 @@ describe('Initialize Nest SDK', () => { _metadata: { sdk: { name: 'sentry.javascript.nestjs', - packages: [{ name: 'npm:@sentry/nestjs', version: SDK_VERSION }], + packages: [ + { name: 'npm:@sentry/nestjs', version: SDK_VERSION }, + { name: 'npm:@sentry/node', version: SDK_VERSION }, + ], version: SDK_VERSION, }, }, From ccf337a660792cbf87874e23f547597ddff5a417 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 19 Mar 2026 17:41:09 +0100 Subject: [PATCH 04/43] fix(deps): bump socket.io-parser to 4.2.6 to fix CVE-2026-33151 (#19880) Fixes Dependabot alert #1223. Bumps socket.io-parser from 4.2.4 to 4.2.6 to address unbounded binary attachment memory exhaustion vulnerability. Co-authored-by: Claude Sonnet 4.6 --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 1237ed9f3caa..4a8f5690a5fe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -27480,9 +27480,9 @@ socket.io-adapter@~2.5.2: ws "~8.17.1" socket.io-parser@~4.2.4: - version "4.2.4" - resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.4.tgz#c806966cf7270601e47469ddeec30fbdfda44c83" - integrity sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew== + version "4.2.6" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.6.tgz#19156bf179af3931abd05260cfb1491822578a6f" + integrity sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg== dependencies: "@socket.io/component-emitter" "~3.1.0" debug "~4.3.1" From 02744f71a169d8e81da6c6feb886ffde4c047aac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:52:10 +0100 Subject: [PATCH 05/43] feat(deps): bump stacktrace-parser from 0.1.10 to 0.1.11 (#19887) Bumps [stacktrace-parser](https://github.com/errwischt/stacktrace-parser) from 0.1.10 to 0.1.11.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=stacktrace-parser&package-manager=npm_and_yarn&previous-version=0.1.10&new-version=0.1.11)](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 show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/nextjs/package.json | 2 +- yarn.lock | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 166ecede87d5..afdd4994ca6a 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -88,7 +88,7 @@ "@sentry/vercel-edge": "10.45.0", "@sentry/webpack-plugin": "^5.1.0", "rollup": "^4.35.0", - "stacktrace-parser": "^0.1.10" + "stacktrace-parser": "^0.1.11" }, "devDependencies": { "eslint-plugin-react": "^7.31.11", diff --git a/yarn.lock b/yarn.lock index 4a8f5690a5fe..8c8104564bc8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -27855,10 +27855,10 @@ stackframe@^1.3.4: resolved "https://registry.yarnpkg.com/stackframe/-/stackframe-1.3.4.tgz#b881a004c8c149a5e8efef37d51b16e412943310" integrity sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw== -stacktrace-parser@^0.1.10: - version "0.1.10" - resolved "https://registry.yarnpkg.com/stacktrace-parser/-/stacktrace-parser-0.1.10.tgz#29fb0cae4e0d0b85155879402857a1639eb6051a" - integrity sha512-KJP1OCML99+8fhOHxwwzyWrlUuVX5GQ0ZpJTd1DFXhdkrvg1szxfHhawXUZ3g9TkXORQd4/WG68jMlQZ2p8wlg== +stacktrace-parser@^0.1.11: + version "0.1.11" + resolved "https://registry.yarnpkg.com/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz#c7c08f9b29ef566b9a6f7b255d7db572f66fabc4" + integrity sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg== dependencies: type-fest "^0.7.1" @@ -28288,6 +28288,7 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" + uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2" From ffe7a6f3746a59bb9172592896246fea5724b24a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:58:57 +0100 Subject: [PATCH 06/43] chore(deps): bump mongodb-memory-server-global from 10.1.4 to 11.0.1 (#19888) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [mongodb-memory-server-global](https://github.com/typegoose/mongodb-memory-server/tree/HEAD/packages/mongodb-memory-server-global) from 10.1.4 to 11.0.1.
Release notes

Sourced from mongodb-memory-server-global's releases.

v11.0.1

11.0.1 (2025-12-23)

Fixes

  • MongoBinaryDownload: use "http" module for "USE_HTTP" requests (9c967f3), closes #962

v11.0.0

11.0.0 (2025-12-15)

⚠ BREAKING CHANGES

  • resolveConfig: Default mongodb binary version is now 8.2.x

  • tsconfig: potentially breaking change upgrading "target" to "es2023" in tsconfig

  • MongoBinaryDownloadUrl: Removed support for mongodb version below 4.2.0

  • Remove "-global-4.0" package as it is now unsupported by the mongodb driver

  • Lowest supported NodeJS version is now 20.19.0

  • tsconfig: update "target" to "es2023" (391da3d)

Features

  • MongoBinaryDownloadUrl: drop support for mongod versions below 4.2.0 (c4e6cf1), closes #894
  • remove "mongodb-memory-server-global-4.0" (9cf7211)
  • resolveConfig: enable "RESUME_DOWNLOAD" by default (5197f69)
  • resolveConfig: update default binary version to 8.2.1 (fefb155)
  • set lowest supported nodejs version to 20.19.0 (5c31896)

Fixes

  • resolveConfig: correct the mapping for "RESUME_DOWNLOAD" (6ae3ebf)

Style

  • migrate to eslint flat config (5a0e75c)
  • MongoBinaryDownload::attemptDownload: remove duplicated argument (3162cc6)
  • MongoBinaryDownload: document more options (ae9784c)

Dependencies

  • mongodb: upgrade to 7.0.0 (8430483)

Dev-Dependencies

  • @​types/jest: upgrade to 30.0.0 (0bd9079)
  • @​types/node: upgrade to version 24.10.0 (a42df53)
  • @typescript-eslint/*: upgrade to 7.18.0 (b6c462c)
  • @typescript-eslint/*: upgrade to 8.31.1 (945c0ed)
  • commitlint: upgrade to 20.1.0 (097237c)
  • commitlint: upgrade to 20.2.0 (c10f800)

... (truncated)

Changelog

Sourced from mongodb-memory-server-global's changelog.

11.0.1 (2025-12-23)

Fixes

  • MongoBinaryDownload: use "http" module for "USE_HTTP" requests (9c967f3), closes #962

11.0.0 (2025-12-15)

⚠ BREAKING CHANGES

  • resolveConfig: Default mongodb binary version is now 8.2.x

  • tsconfig: potentially breaking change upgrading "target" to "es2023" in tsconfig

  • MongoBinaryDownloadUrl: Removed support for mongodb version below 4.2.0

  • Remove "-global-4.0" package as it is now unsupported by the mongodb driver

  • Lowest supported NodeJS version is now 20.19.0

  • tsconfig: update "target" to "es2023" (391da3d)

Features

  • MongoBinaryDownloadUrl: drop support for mongod versions below 4.2.0 (c4e6cf1), closes #894
  • remove "mongodb-memory-server-global-4.0" (9cf7211)
  • resolveConfig: enable "RESUME_DOWNLOAD" by default (5197f69)
  • resolveConfig: update default binary version to 8.2.1 (fefb155)
  • set lowest supported nodejs version to 20.19.0 (5c31896)

Fixes

  • resolveConfig: correct the mapping for "RESUME_DOWNLOAD" (6ae3ebf)

Style

  • migrate to eslint flat config (5a0e75c)
  • MongoBinaryDownload::attemptDownload: remove duplicated argument (3162cc6)
  • MongoBinaryDownload: document more options (ae9784c)

Dependencies

  • mongodb: upgrade to 7.0.0 (8430483)

Dev-Dependencies

  • @​types/jest: upgrade to 30.0.0 (0bd9079)
  • @​types/node: upgrade to version 24.10.0 (a42df53)
  • @typescript-eslint/*: upgrade to 7.18.0 (b6c462c)
  • @typescript-eslint/*: upgrade to 8.31.1 (945c0ed)
  • commitlint: upgrade to 20.1.0 (097237c)
  • commitlint: upgrade to 20.2.0 (c10f800)
  • cross-env: drop seemingly unused dependency (c1d0cdb)
  • eslint-config-prettier: upgrade to 10.1.2 (0114ef2)

... (truncated)

Commits
Maintainer changes

This version was pushed to npm by [GitHub Actions](https://www.npmjs.com/~GitHub Actions), a new releaser for mongodb-memory-server-global since your current version.


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=mongodb-memory-server-global&package-manager=npm_and_yarn&previous-version=10.1.4&new-version=11.0.1)](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 show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Lukas Stracke --- .../node-integration-tests/package.json | 2 +- yarn.lock | 112 +++++++++--------- 2 files changed, 57 insertions(+), 57 deletions(-) diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index e33ef4e6b42c..d4df98f7c37d 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -64,7 +64,7 @@ "knex": "^2.5.1", "lru-memoizer": "2.3.0", "mongodb": "^3.7.3", - "mongodb-memory-server-global": "^10.1.4", + "mongodb-memory-server-global": "^11.0.1", "mongoose": "^6.13.6", "mysql": "^2.18.1", "mysql2": "^3.19.1", diff --git a/yarn.lock b/yarn.lock index 8c8104564bc8..aaf233ed0f71 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5350,10 +5350,10 @@ resolved "https://registry.yarnpkg.com/@mjackson/node-fetch-server/-/node-fetch-server-0.2.0.tgz#577c0c25d8aae9f69a97738b7b0d03d1471cdc49" integrity sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng== -"@mongodb-js/saslprep@^1.1.0", "@mongodb-js/saslprep@^1.1.9": - version "1.4.5" - resolved "https://registry.yarnpkg.com/@mongodb-js/saslprep/-/saslprep-1.4.5.tgz#0f53a6c5a350fbe4bfa12cc80b69e8d358f1bbc0" - integrity sha512-k64Lbyb7ycCSXHSLzxVdb2xsKGPMvYZfCICXvDsI8Z65CeWQzTEKS4YmGbnqw+U9RBvLPTsB6UCmwkgsDTGWIw== +"@mongodb-js/saslprep@^1.1.0", "@mongodb-js/saslprep@^1.3.0": + version "1.4.6" + resolved "https://registry.yarnpkg.com/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz#2edf5819fa0e69d86059f44d1fe57ae9d7817c12" + integrity sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g== dependencies: sparse-bitfield "^3.0.3" @@ -9911,10 +9911,10 @@ resolved "https://registry.yarnpkg.com/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz#1306dbfa53768bcbcfc95a1c8cde367975581859" integrity sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA== -"@types/whatwg-url@^11.0.2": - version "11.0.5" - resolved "https://registry.yarnpkg.com/@types/whatwg-url/-/whatwg-url-11.0.5.tgz#aaa2546e60f0c99209ca13360c32c78caf2c409f" - integrity sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ== +"@types/whatwg-url@^13.0.0": + version "13.0.0" + resolved "https://registry.yarnpkg.com/@types/whatwg-url/-/whatwg-url-13.0.0.tgz#2b11e32772fd321c0dedf4d655953ea8ce587b2a" + integrity sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q== dependencies: "@types/webidl-conversions" "*" @@ -12816,10 +12816,10 @@ bser@2.1.1: dependencies: node-int64 "^0.4.0" -bson@*, bson@^6.10.3: - version "6.10.3" - resolved "https://registry.yarnpkg.com/bson/-/bson-6.10.3.tgz#5f9a463af6b83e264bedd08b236d1356a30eda47" - integrity sha512-MTxGsqgYTwfshYWTRdmZRC+M7FnG1b4y7RO7p2k3X24Wq0yv1m77Wsj0BzlPzd/IowgESfsruQCUToa7vbOpPQ== +bson@*, bson@^7.1.1: + version "7.2.0" + resolved "https://registry.yarnpkg.com/bson/-/bson-7.2.0.tgz#1a496a42d9ff130b9f3ab8efd465459c758c747f" + integrity sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ== bson@^1.1.4: version "1.1.6" @@ -14359,7 +14359,7 @@ debug@2, debug@2.6.9, debug@^2.1.0, debug@^2.1.1, debug@^2.1.3, debug@^2.2.0, de dependencies: ms "2.0.0" -debug@4, debug@4.x, debug@^4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@^4.3.5, debug@^4.3.7, debug@^4.4.0, debug@^4.4.1, debug@^4.4.3: +debug@4, debug@4.x, debug@^4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@^4.3.5, debug@^4.4.0, debug@^4.4.1, debug@^4.4.3: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== @@ -17551,7 +17551,7 @@ fn.name@1.x.x: resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc" integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== -follow-redirects@^1.0.0, follow-redirects@^1.15.11, follow-redirects@^1.15.9: +follow-redirects@^1.0.0, follow-redirects@^1.15.11: version "1.15.11" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340" integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ== @@ -18975,7 +18975,7 @@ https-proxy-agent@5.0.1, https-proxy-agent@^5.0.0, https-proxy-agent@^5.0.1: agent-base "6" debug "4" -https-proxy-agent@^7.0.0, https-proxy-agent@^7.0.1, https-proxy-agent@^7.0.5: +https-proxy-agent@^7.0.0, https-proxy-agent@^7.0.1, https-proxy-agent@^7.0.5, https-proxy-agent@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9" integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== @@ -22296,39 +22296,39 @@ mongodb-connection-string-url@^2.6.0: "@types/whatwg-url" "^8.2.1" whatwg-url "^11.0.0" -mongodb-connection-string-url@^3.0.0: - version "3.0.2" - resolved "https://registry.yarnpkg.com/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz#e223089dfa0a5fa9bf505f8aedcbc67b077b33e7" - integrity sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA== +mongodb-connection-string-url@^7.0.0: + version "7.0.1" + resolved "https://registry.yarnpkg.com/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz#347b664cd9e6ddff10d5c1c6010d6d8dbfe9272d" + integrity sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ== dependencies: - "@types/whatwg-url" "^11.0.2" - whatwg-url "^14.1.0 || ^13.0.0" + "@types/whatwg-url" "^13.0.0" + whatwg-url "^14.1.0" -mongodb-memory-server-core@10.1.4: - version "10.1.4" - resolved "https://registry.yarnpkg.com/mongodb-memory-server-core/-/mongodb-memory-server-core-10.1.4.tgz#aaeab3dfb13a1495dedd2f4af1eee815792b8fb9" - integrity sha512-o8fgY7ZalEd8pGps43fFPr/hkQu1L8i6HFEGbsTfA2zDOW0TopgpswaBCqDr0qD7ptibyPfB5DmC+UlIxbThzA== +mongodb-memory-server-core@11.0.1: + version "11.0.1" + resolved "https://registry.yarnpkg.com/mongodb-memory-server-core/-/mongodb-memory-server-core-11.0.1.tgz#952709a231acad192c3cd8b9795b15cdf2a04ff6" + integrity sha512-IcIb2S9Xf7Lmz43Z1ZujMqNg7PU5Q7yn+4wOnu7l6pfeGPkEmlqzV1hIbroVx8s4vXhPB1oMGC1u8clW7aj3Xw== dependencies: async-mutex "^0.5.0" camelcase "^6.3.0" - debug "^4.3.7" + debug "^4.4.3" find-cache-dir "^3.3.2" - follow-redirects "^1.15.9" - https-proxy-agent "^7.0.5" - mongodb "^6.9.0" + follow-redirects "^1.15.11" + https-proxy-agent "^7.0.6" + mongodb "^7.0.0" new-find-package-json "^2.0.0" - semver "^7.6.3" + semver "^7.7.3" tar-stream "^3.1.7" - tslib "^2.7.0" - yauzl "^3.1.3" + tslib "^2.8.1" + yauzl "^3.2.0" -mongodb-memory-server-global@^10.1.4: - version "10.1.4" - resolved "https://registry.yarnpkg.com/mongodb-memory-server-global/-/mongodb-memory-server-global-10.1.4.tgz#b902ae141775dcea5530482d2ddb45418df79d86" - integrity sha512-YMe7XVkGfwyD7Uwn9G8jejMxy0+sP/2o3D4QU5AvmsioJGppT+z+tk6dxzKhi2xc7TRnY+CTGX6OrqscYiDHyg== +mongodb-memory-server-global@^11.0.1: + version "11.0.1" + resolved "https://registry.yarnpkg.com/mongodb-memory-server-global/-/mongodb-memory-server-global-11.0.1.tgz#dd7649fee1200506b47e2ba822f6010b3372086a" + integrity sha512-UC5ESWzGVlgdKWQMr6C0M/6lhLGSq+J+hz1dGDlqk0giOEXK4FW4Y0blhqLiaZ95xgk2sWZ6Ek2sLooW0hmjlA== dependencies: - mongodb-memory-server-core "10.1.4" - tslib "^2.7.0" + mongodb-memory-server-core "11.0.1" + tslib "^2.8.1" mongodb@4.17.2: version "4.17.2" @@ -22355,14 +22355,14 @@ mongodb@^3.7.3: optionalDependencies: saslprep "^1.0.0" -mongodb@^6.9.0: - version "6.13.1" - resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-6.13.1.tgz#924319f957a22efda45a96d38c08a594fd7929fa" - integrity sha512-gdq40tX8StmhP6akMp1pPoEVv+9jTYFSrga/g23JxajPAQhH39ysZrHGzQCSd9PEOnuEQEdjIWqxO7ZSwC0w7Q== +mongodb@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-7.1.0.tgz#c13640259d30cdf9c3936aaa003f0d0c0a34fa9b" + integrity sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg== dependencies: - "@mongodb-js/saslprep" "^1.1.9" - bson "^6.10.3" - mongodb-connection-string-url "^3.0.0" + "@mongodb-js/saslprep" "^1.3.0" + bson "^7.1.1" + mongodb-connection-string-url "^7.0.0" mongoose@^6.13.6: version "6.13.9" @@ -28940,10 +28940,10 @@ tr46@^4.1.1: dependencies: punycode "^2.3.0" -tr46@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/tr46/-/tr46-5.0.0.tgz#3b46d583613ec7283020d79019f1335723801cec" - integrity sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g== +tr46@^5.1.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-5.1.1.tgz#96ae867cddb8fdb64a49cc3059a8d428bcf238ca" + integrity sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw== dependencies: punycode "^2.3.1" @@ -29077,7 +29077,7 @@ tslib@2.4.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== -tslib@2.8.1, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.4.1, tslib@^2.5.0, tslib@^2.6.2, tslib@^2.6.3, tslib@^2.7.0, tslib@^2.8.1: +tslib@2.8.1, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.4.1, tslib@^2.5.0, tslib@^2.6.2, tslib@^2.6.3, tslib@^2.8.1: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -30634,12 +30634,12 @@ whatwg-url@^12.0.0, whatwg-url@^12.0.1: tr46 "^4.1.1" webidl-conversions "^7.0.0" -"whatwg-url@^14.1.0 || ^13.0.0": - version "14.1.1" - resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-14.1.1.tgz#ce71e240c61541315833b5cdafd139a479e47058" - integrity sha512-mDGf9diDad/giZ/Sm9Xi2YcyzaFpbdLpJPr+E9fSkyQ7KpQD4SdFcugkRQYzhmfI4KeV4Qpnn2sKPdo+kmsgRQ== +whatwg-url@^14.1.0: + version "14.2.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-14.2.0.tgz#4ee02d5d725155dae004f6ae95c73e7ef5d95663" + integrity sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw== dependencies: - tr46 "^5.0.0" + tr46 "^5.1.0" webidl-conversions "^7.0.0" whatwg-url@^5.0.0: @@ -31137,7 +31137,7 @@ yarn-deduplicate@6.0.2: semver "^7.5.0" tslib "^2.5.0" -yauzl@^3.1.3: +yauzl@^3.2.0: version "3.2.1" resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-3.2.1.tgz#d35befb9a0fdd328da41926be895ade2de14dbe7" integrity sha512-k1isifdbpNSFEHFJ1ZY4YDewv0IH9FR61lDetaRMD3j2ae3bIXGV+7c+LHCqtQGofSd8PIyV4X6+dHMAnSr60A== From ffe00a749ba78fea364693c927f9c8e75054b388 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 19 Mar 2026 18:59:33 +0100 Subject: [PATCH 07/43] chore(node-integration-tests): Remove unnecessary `file-type` dependency (#19824) It seems like we added `file-type` as a dependency in #16751 with the reason that yarn was complaining about a missing peer dependency. However, `file-type` is not a peer dependency but a dependency of `@nestjs/common`. So it should be installed anyway. Given CI passes, I'd rather remove it for now. --- dev-packages/node-integration-tests/package.json | 1 - yarn.lock | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index d4df98f7c37d..f171d14f0ee4 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -91,7 +91,6 @@ "@types/node-cron": "^3.0.11", "@types/node-schedule": "^2.1.7", "eslint-plugin-regexp": "^1.15.0", - "file-type": "^21.3.1", "globby": "11", "react": "^18.3.1", "zod": "^3.24.1" diff --git a/yarn.lock b/yarn.lock index aaf233ed0f71..45bc20340cb7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17277,7 +17277,7 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" -file-type@21.3.2, file-type@^21.3.1: +file-type@21.3.2: version "21.3.2" resolved "https://registry.npmjs.org/file-type/-/file-type-21.3.2.tgz" integrity sha512-DLkUvGwep3poOV2wpzbHCOnSKGk1LzyXTv+aHFgN2VFl96wnp8YA9YjO2qPzg5PuL8q/SW9Pdi6WTkYOIh995w== From 0684e2dc0a4c4c2c8f21fdcd2573bf93b7f84950 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:02:55 +0100 Subject: [PATCH 08/43] handleCallbackErrors return type fix --- .../thenable-with-extra-methods/subject.js | 89 +++++++++++++ .../thenable-with-extra-methods/test.ts | 69 ++++++++++ .../core/src/utils/handleCallbackErrors.ts | 126 ++++++++++++++++-- .../utils/handleCallbackErrors-proxy.test.ts | 82 ++++++++++++ 4 files changed, 353 insertions(+), 13 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/test.ts create mode 100644 packages/core/test/lib/utils/handleCallbackErrors-proxy.test.ts diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/subject.js b/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/subject.js new file mode 100644 index 000000000000..f09573466ce4 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/subject.js @@ -0,0 +1,89 @@ +/** + * Test that verifies thenable objects with extra methods (like jQuery's jqXHR) + * preserve those methods when returned from Sentry.startSpan(). + * + * This tests the Proxy fix for the GitHub issue where: + * const jqXHR = Sentry.startSpan({ name: "test" }, () => $.ajax(...)); + * jqXHR.abort(); // Should work! + */ + +// Mock a jqXHR-like object (simulates jQuery.ajax() return value) +function createMockJqXHR() { + let resolvePromise; + const promise = new Promise(resolve => { + resolvePromise = resolve; + }); + + console.log(''); + + // Create an object that has both Promise methods and XHR methods + const mockJqXHR = { + then: promise.then.bind(promise), + catch: promise.catch.bind(promise), + finally: promise.finally.bind(promise), + abort: function () { + window.jqXHRAbortCalled = true; + window.jqXHRAbortResult = 'abort-successful'; + return 'abort-successful'; + }, + // XHR-like properties + status: 0, + readyState: 1, + responseText: '', + }; + + // Resolve after a short delay + //setTimeout(() => resolvePromise({ data: 'test response' }), 50); + + return mockJqXHR; +} + +async function runTest() { + window.jqXHRAbortCalled = false; + window.jqXHRAbortResult = null; + window.jqXHRTestError = null; + + try { + // This simulates: const jqXHR = Sentry.startSpan(() => $.ajax(...)); + const result = Sentry.startSpan({ name: 'test-jqxhr', op: 'http.client' }, () => { + const dd = createMockJqXHR(); + const hasAbort = typeof dd.abort === 'function'; + const hasStatus = 'status' in dd; + const hasReadyState = 'readyState' in dd; + + console.log('ddhasAbort:', hasAbort, 'hasStatus:', hasStatus, 'hasReadyState:', hasReadyState); + return dd; + }); + + console.log('result from startSpan:', result); + + // Check if extra methods are preserved via Proxy + const hasAbort = typeof result.abort === 'function'; + const hasStatus = 'status' in result; + const hasReadyState = 'readyState' in result; + + console.log('hasAbort:', hasAbort, 'hasStatus:', hasStatus, 'hasReadyState:', hasReadyState); + + if (hasAbort && hasStatus && hasReadyState) { + // Call abort() to test it works + const abortResult = result.abort(); + window.jqXHRMethodsPreserved = true; + window.jqXHRAbortReturnValue = abortResult; + } else { + window.jqXHRMethodsPreserved = false; + } + + // Verify promise functionality still works + try { + await result; + window.jqXHRPromiseResolved = true; + } catch (err) { + window.jqXHRPromiseResolved = false; + } + } catch (error) { + window.jqXHRTestError = error.message; + window.jqXHRMethodsPreserved = false; + } +} + +runTest(); diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/test.ts b/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/test.ts new file mode 100644 index 000000000000..6c25d2aa774e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/test.ts @@ -0,0 +1,69 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers'; + +/** + * These tests verify that thenable objects with extra methods (like jQuery's jqXHR) + * preserve those methods when returned from startSpan(). + * + * Tests the Proxy fix that allows code like this to work: + * const jqXHR = Sentry.startSpan({ name: "test" }, () => $.ajax(...)); + * jqXHR.abort(); // Now works! ✅ + */ + +sentryTest('preserves extra methods on jqXHR-like thenable objects', async ({ getLocalTestUrl, page }) => { + page.on('console', msg => { + console.log(`Console log from page: ${msg.text()}`); + }); + + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + // Wait for the transaction to be sent + const transactionPromise = waitForTransactionRequest(page); + + await page.goto(url); + + // Wait for test to complete + await page.waitForTimeout(200); + + // Verify extra methods are preserved + const methodsPreserved = await page.evaluate(() => (window as any).jqXHRMethodsPreserved); + expect(methodsPreserved).toBe(true); + + // Verify abort() was actually called + const abortCalled = await page.evaluate(() => (window as any).jqXHRAbortCalled); + expect(abortCalled).toBe(true); + + // Verify abort() returned the correct value + const abortReturnValue = await page.evaluate(() => (window as any).jqXHRAbortReturnValue); + expect(abortReturnValue).toBe('abort-successful'); + + // Verify no errors occurred + const testError = await page.evaluate(() => (window as any).jqXHRTestError); + expect(testError).toBeNull(); + + // Verify the span was created and sent + const transaction = envelopeRequestParser(await transactionPromise); + expect(transaction.transaction).toBe('test-jqxhr'); + expect(transaction.spans).toBeDefined(); +}); + +sentryTest('preserved methods maintain promise functionality', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + // Wait for promise to resolve + await page.waitForTimeout(200); + + // Verify promise resolved correctly despite having extra methods + const promiseResolved = await page.evaluate(() => (window as any).jqXHRPromiseResolved); + expect(promiseResolved).toBe(true); +}); diff --git a/packages/core/src/utils/handleCallbackErrors.ts b/packages/core/src/utils/handleCallbackErrors.ts index 1a09e23a40aa..62ccde6eebed 100644 --- a/packages/core/src/utils/handleCallbackErrors.ts +++ b/packages/core/src/utils/handleCallbackErrors.ts @@ -62,7 +62,12 @@ export function handleCallbackErrors< * Maybe handle a promise rejection. * This expects to be given a value that _may_ be a promise, or any other value. * If it is a promise, and it rejects, it will call the `onError` callback. - * Other than this, it will generally return the given value as-is. + * + * For thenable objects with extra methods (like jQuery's jqXHR), + * this function preserves those methods by wrapping the original thenable in a Proxy + * that intercepts .then() calls to apply error handling while forwarding all other + * properties to the original object. + * This allows code like `startSpan(() => $.ajax(...)).abort()` to work correctly. */ function maybeHandlePromiseRejection( value: MaybePromise, @@ -71,22 +76,117 @@ function maybeHandlePromiseRejection( onSuccess: (result: MaybePromise | AwaitedPromise) => void, ): MaybePromise { if (isThenable(value)) { - // @ts-expect-error - the isThenable check returns the "wrong" type here - return value.then( - res => { - onFinally(); - onSuccess(res); - return res; - }, - e => { - onError(e); - onFinally(); - throw e; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const hasAbort = typeof value.abort === 'function'; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const hasStatus = 'status' in value; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const hasReadyState = 'readyState' in value; + console.log('[ORIGINAL] valuehasAbort:', hasAbort, 'hasStatus:', hasStatus, 'hasReadyState:', hasReadyState); + + // Track whether we've already attached handlers to avoid calling callbacks multiple times + let handlersAttached = false; + + // Wrap the original value directly to preserve all its methods + return new Proxy(value, { + get(target, prop, receiver) { + console.log(`[PROXY GET] Accessing property: "${String(prop)}"`); + + // Special handling for .then() - intercept it to add error handling + if (prop === 'then' && typeof target.then === 'function') { + console.log('[PROXY] Intercepting .then() call'); + return function ( + onfulfilled?: ((value: unknown) => unknown) | null, + onrejected?: ((reason: unknown) => unknown) | null, + ) { + // Only attach handlers once to avoid calling callbacks multiple times + if (!handlersAttached) { + handlersAttached = true; + + // Wrap the fulfillment handler to call our callbacks + const wrappedOnFulfilled = onfulfilled + ? (res: unknown) => { + onFinally(); + onSuccess(res as MaybePromise); + return onfulfilled(res); + } + : (res: unknown) => { + onFinally(); + onSuccess(res as MaybePromise); + return res; + }; + + // Wrap the rejection handler to call our callbacks + const wrappedOnRejected = onrejected + ? (err: unknown) => { + onError(err); + onFinally(); + return onrejected(err); + } + : (err: unknown) => { + onError(err); + onFinally(); + throw err; + }; + + // Call the original .then() with our wrapped handlers + const thenResult = target.then.call(target, wrappedOnFulfilled, wrappedOnRejected); + + // CRITICAL: jQuery's .then() returns a new Deferred object without .abort() + // We need to wrap this result in a Proxy that falls back to the original object + return new Proxy(thenResult, { + get(thenTarget, thenProp) { + console.log(`[THEN-PROXY GET] Accessing property: "${String(thenProp)}"`); + // First try the result of .then() + const thenValue = Reflect.get(thenTarget, thenProp, thenTarget); + if (thenValue !== undefined) { + console.log(`[THEN-PROXY] Getting "${String(thenProp)}" from then result:`, typeof thenValue); + return typeof thenValue === 'function' ? thenValue.bind(thenTarget) : thenValue; + } + + // Fall back to the ORIGINAL object for properties like .abort() + const originalValue = Reflect.get(target, thenProp, target); + if (originalValue !== undefined) { + console.log( + `[THEN-PROXY] Getting "${String(thenProp)}" from ORIGINAL object:`, + typeof originalValue, + ); + return typeof originalValue === 'function' ? originalValue.bind(target) : originalValue; + } + + return undefined; + }, + }); + } else { + // Subsequent .then() calls just pass through without wrapping + return target.then.call(target, onfulfilled, onrejected); + } + }; + } + + // For all other properties, forward to the original object + const originalValue = Reflect.get(target, prop, target); + console.log(`[PROXY] Getting property "${String(prop)}" from original:`, typeof originalValue); + + if (originalValue !== undefined) { + // Bind methods to preserve 'this' context + return typeof originalValue === 'function' ? originalValue.bind(target) : originalValue; + } + + return undefined; }, - ); + }); } onFinally(); onSuccess(value); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const hasAbort = typeof value.abort === 'function'; + console.log('[NON-THENABLE] valuehasAbort:', hasAbort); return value; } diff --git a/packages/core/test/lib/utils/handleCallbackErrors-proxy.test.ts b/packages/core/test/lib/utils/handleCallbackErrors-proxy.test.ts new file mode 100644 index 000000000000..534d4eb4085d --- /dev/null +++ b/packages/core/test/lib/utils/handleCallbackErrors-proxy.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest'; +import { handleCallbackErrors } from '../../../src/utils/handleCallbackErrors'; + +describe('handleCallbackErrors - Proxy for thenable objects', () => { + it('preserves extra methods on thenable objects (jQuery jqXHR use case)', async () => { + const onError = vi.fn(); + const onFinally = vi.fn(); + + // Mock a JQuery jqXHR-like object with both Promise and XHR methods + let resolvePromise: (value: unknown) => void; + const promise = new Promise(resolve => { + resolvePromise = resolve; + }); + + const mockJqXHR = { + then: promise.then.bind(promise), + catch: promise.catch.bind(promise), + abort: vi.fn(() => 'abort-successful'), + status: 0, + readyState: 1, + responseText: '', + }; + + const fn = vi.fn(() => mockJqXHR); + + const result = handleCallbackErrors(fn, onError, onFinally); + + // Verify the result is thenable + expect(typeof result.then).toBe('function'); + + // Important: Verify extra methods are preserved via Proxy + expect(typeof result.abort).toBe('function'); + expect(typeof result.status).toBe('number'); + expect(typeof result.readyState).toBe('number'); + + const abortResult = result.abort(); + expect(abortResult).toBe('abort-successful'); + expect(mockJqXHR.abort).toHaveBeenCalledTimes(1); + + // Verify promise functionality still works + resolvePromise!({ data: 'test' }); + const promiseResult = await result; + expect(promiseResult).toEqual({ data: 'test' }); + expect(onFinally).toHaveBeenCalledTimes(1); + }); + + it('preserves method binding context', async () => { + const onError = vi.fn(); + + let resolvePromise: (value: unknown) => void; + const promise = new Promise(resolve => { + resolvePromise = resolve; + }); + + const mockJqXHR = { + then: promise.then.bind(promise), + _internalState: 'test-state', + getState: function () { + return this._internalState; + }, + }; + + const fn = vi.fn(() => mockJqXHR); + const result = handleCallbackErrors(fn, onError); + + // Verify method is bound to original object + expect(result.getState()).toBe('test-state'); + + resolvePromise!('done'); + await result; + }); + + it('does not affect non-thenable values', () => { + const onError = vi.fn(); + const fn = vi.fn(() => 'plain-value'); + + const result = handleCallbackErrors(fn, onError); + + expect(result).toBe('plain-value'); + expect(fn).toHaveBeenCalledTimes(1); + }); +}); From da2ff208fa4c9e58967b1c716cff5604ee02bc2e Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:45:12 +0100 Subject: [PATCH 09/43] with `has` object trap --- .../thenable-with-extra-methods/subject.js | 103 ++++++++++-------- .../thenable-with-extra-methods/test.ts | 24 ++-- .../core/src/utils/handleCallbackErrors.ts | 49 +++------ .../utils/handleCallbackErrors-proxy.test.ts | 8 +- 4 files changed, 93 insertions(+), 91 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/subject.js b/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/subject.js index f09573466ce4..35d0adf50ea6 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/subject.js +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/subject.js @@ -7,36 +7,22 @@ * jqXHR.abort(); // Should work! */ -// Mock a jqXHR-like object (simulates jQuery.ajax() return value) -function createMockJqXHR() { - let resolvePromise; - const promise = new Promise(resolve => { - resolvePromise = resolve; - }); +// Load jQuery from CDN +const script = document.createElement('script'); +script.src = 'https://code.jquery.com/jquery-3.7.1.min.js'; +script.integrity = 'sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo='; +script.crossOrigin = 'anonymous'; - console.log(''); +script.onload = function () { + runTest(); +}; - // Create an object that has both Promise methods and XHR methods - const mockJqXHR = { - then: promise.then.bind(promise), - catch: promise.catch.bind(promise), - finally: promise.finally.bind(promise), - abort: function () { - window.jqXHRAbortCalled = true; - window.jqXHRAbortResult = 'abort-successful'; - return 'abort-successful'; - }, - // XHR-like properties - status: 0, - readyState: 1, - responseText: '', - }; +script.onerror = function () { + window.jqXHRTestError = 'Failed to load jQuery'; + window.jqXHRMethodsPreserved = false; +}; - // Resolve after a short delay - //setTimeout(() => resolvePromise({ data: 'test response' }), 50); - - return mockJqXHR; -} +document.head.appendChild(script); async function runTest() { window.jqXHRAbortCalled = false; @@ -44,46 +30,69 @@ async function runTest() { window.jqXHRTestError = null; try { - // This simulates: const jqXHR = Sentry.startSpan(() => $.ajax(...)); + if (!window.jQuery) { + throw new Error('jQuery not loaded'); + } + + // Real-world test: Wrap actual jQuery $.ajax() call in startSpan const result = Sentry.startSpan({ name: 'test-jqxhr', op: 'http.client' }, () => { - const dd = createMockJqXHR(); - const hasAbort = typeof dd.abort === 'function'; - const hasStatus = 'status' in dd; - const hasReadyState = 'readyState' in dd; + // Make a real AJAX request with jQuery + const d = window.jQuery.ajax({ + url: 'https://httpbin.org/status/200', + method: 'GET', + timeout: 5000, + }); + // Check if jqXHR methods are preserved + const hasAbort1 = typeof d.abort === 'function'; + const hasStatus1 = 'status' in d; + const hasReadyState1 = 'readyState' in d; - console.log('ddhasAbort:', hasAbort, 'hasStatus:', hasStatus, 'hasReadyState:', hasReadyState); - return dd; - }); + console.log('jqXHR methods preserved:', d.readyState, { hasAbort1, hasStatus1, hasReadyState1 }); - console.log('result from startSpan:', result); + return d; + }); - // Check if extra methods are preserved via Proxy + // Check if jqXHR methods are preserved using 'in' operator (tests has trap) const hasAbort = typeof result.abort === 'function'; - const hasStatus = 'status' in result; const hasReadyState = 'readyState' in result; - console.log('hasAbort:', hasAbort, 'hasStatus:', hasStatus, 'hasReadyState:', hasReadyState); + console.log('Result from startSpan:', result.toString(), Object.keys(result)); + + console.log('jqXHR methods preserved:', { + hasAbort, + hasReadyState, + readyStateValue: result.readyState, + abortType: typeof result.abort, + }); - if (hasAbort && hasStatus && hasReadyState) { + if (hasAbort && hasReadyState) { // Call abort() to test it works - const abortResult = result.abort(); - window.jqXHRMethodsPreserved = true; - window.jqXHRAbortReturnValue = abortResult; + try { + result.abort(); + window.jqXHRAbortCalled = true; + window.jqXHRAbortResult = 'abort-successful'; + window.jqXHRMethodsPreserved = true; + } catch (e) { + window.jqXHRTestError = `abort() failed: ${e.message}`; + window.jqXHRMethodsPreserved = false; + } } else { window.jqXHRMethodsPreserved = false; + window.jqXHRTestError = 'jqXHR methods not preserved'; } - // Verify promise functionality still works + // Since we aborted the request, it should be rejected try { await result; - window.jqXHRPromiseResolved = true; + window.jqXHRPromiseResolved = true; // Unexpected } catch (err) { + // Expected: aborted request rejects window.jqXHRPromiseResolved = false; + window.jqXHRPromiseRejected = true; } } catch (error) { + console.error('Test error:', error); window.jqXHRTestError = error.message; window.jqXHRMethodsPreserved = false; } } - -runTest(); diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/test.ts b/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/test.ts index 6c25d2aa774e..c02516a20dc3 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/test.ts @@ -11,7 +11,7 @@ import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest * jqXHR.abort(); // Now works! ✅ */ -sentryTest('preserves extra methods on jqXHR-like thenable objects', async ({ getLocalTestUrl, page }) => { +sentryTest('preserves extra methods on real jQuery jqXHR objects', async ({ getLocalTestUrl, page }) => { page.on('console', msg => { console.log(`Console log from page: ${msg.text()}`); }); @@ -27,8 +27,8 @@ sentryTest('preserves extra methods on jqXHR-like thenable objects', async ({ ge await page.goto(url); - // Wait for test to complete - await page.waitForTimeout(200); + // Wait for jQuery to load and test to complete + await page.waitForTimeout(1000); // Verify extra methods are preserved const methodsPreserved = await page.evaluate(() => (window as any).jqXHRMethodsPreserved); @@ -38,8 +38,8 @@ sentryTest('preserves extra methods on jqXHR-like thenable objects', async ({ ge const abortCalled = await page.evaluate(() => (window as any).jqXHRAbortCalled); expect(abortCalled).toBe(true); - // Verify abort() returned the correct value - const abortReturnValue = await page.evaluate(() => (window as any).jqXHRAbortReturnValue); + // Verify abort() returned successfully + const abortReturnValue = await page.evaluate(() => (window as any).jqXHRAbortResult); expect(abortReturnValue).toBe('abort-successful'); // Verify no errors occurred @@ -52,7 +52,7 @@ sentryTest('preserves extra methods on jqXHR-like thenable objects', async ({ ge expect(transaction.spans).toBeDefined(); }); -sentryTest('preserved methods maintain promise functionality', async ({ getLocalTestUrl, page }) => { +sentryTest('aborted request rejects promise correctly', async ({ getLocalTestUrl, page }) => { if (shouldSkipTracingTest()) { sentryTest.skip(); } @@ -60,10 +60,14 @@ sentryTest('preserved methods maintain promise functionality', async ({ getLocal const url = await getLocalTestUrl({ testDir: __dirname }); await page.goto(url); - // Wait for promise to resolve - await page.waitForTimeout(200); + // Wait for jQuery to load and test to complete + await page.waitForTimeout(1000); - // Verify promise resolved correctly despite having extra methods + // Verify the aborted request was rejected (not resolved) + const promiseRejected = await page.evaluate(() => (window as any).jqXHRPromiseRejected); + expect(promiseRejected).toBe(true); + + // Should NOT have resolved const promiseResolved = await page.evaluate(() => (window as any).jqXHRPromiseResolved); - expect(promiseResolved).toBe(true); + expect(promiseResolved).toBe(false); }); diff --git a/packages/core/src/utils/handleCallbackErrors.ts b/packages/core/src/utils/handleCallbackErrors.ts index 62ccde6eebed..e3cdd860ad0e 100644 --- a/packages/core/src/utils/handleCallbackErrors.ts +++ b/packages/core/src/utils/handleCallbackErrors.ts @@ -76,28 +76,14 @@ function maybeHandlePromiseRejection( onSuccess: (result: MaybePromise | AwaitedPromise) => void, ): MaybePromise { if (isThenable(value)) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const hasAbort = typeof value.abort === 'function'; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const hasStatus = 'status' in value; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const hasReadyState = 'readyState' in value; - console.log('[ORIGINAL] valuehasAbort:', hasAbort, 'hasStatus:', hasStatus, 'hasReadyState:', hasReadyState); - // Track whether we've already attached handlers to avoid calling callbacks multiple times let handlersAttached = false; - // Wrap the original value directly to preserve all its methods + // 1. Wrap the original thenable in a Proxy to preserve all its methods return new Proxy(value, { get(target, prop, receiver) { - console.log(`[PROXY GET] Accessing property: "${String(prop)}"`); - // Special handling for .then() - intercept it to add error handling if (prop === 'then' && typeof target.then === 'function') { - console.log('[PROXY] Intercepting .then() call'); return function ( onfulfilled?: ((value: unknown) => unknown) | null, onrejected?: ((reason: unknown) => unknown) | null, @@ -135,33 +121,32 @@ function maybeHandlePromiseRejection( // Call the original .then() with our wrapped handlers const thenResult = target.then.call(target, wrappedOnFulfilled, wrappedOnRejected); - // CRITICAL: jQuery's .then() returns a new Deferred object without .abort() - // We need to wrap this result in a Proxy that falls back to the original object + // 2. Some thenable implementations (like jQuery) return a new object from .then() + // that doesn't include custom properties from the original (like .abort()). + // We wrap the result in another Proxy to preserve access to those properties. return new Proxy(thenResult, { get(thenTarget, thenProp) { - console.log(`[THEN-PROXY GET] Accessing property: "${String(thenProp)}"`); - // First try the result of .then() + // First try to get the property from the .then() result const thenValue = Reflect.get(thenTarget, thenProp, thenTarget); if (thenValue !== undefined) { - console.log(`[THEN-PROXY] Getting "${String(thenProp)}" from then result:`, typeof thenValue); return typeof thenValue === 'function' ? thenValue.bind(thenTarget) : thenValue; } - // Fall back to the ORIGINAL object for properties like .abort() + // Fall back to the original object for properties like .abort() const originalValue = Reflect.get(target, thenProp, target); if (originalValue !== undefined) { - console.log( - `[THEN-PROXY] Getting "${String(thenProp)}" from ORIGINAL object:`, - typeof originalValue, - ); return typeof originalValue === 'function' ? originalValue.bind(target) : originalValue; } return undefined; }, + has(thenTarget, thenProp) { + // Check if property exists in either the .then() result or the original object + return thenProp in thenTarget || thenProp in (target as object); + }, }); } else { - // Subsequent .then() calls just pass through without wrapping + // Subsequent .then() calls pass through without additional wrapping return target.then.call(target, onfulfilled, onrejected); } }; @@ -169,8 +154,6 @@ function maybeHandlePromiseRejection( // For all other properties, forward to the original object const originalValue = Reflect.get(target, prop, target); - console.log(`[PROXY] Getting property "${String(prop)}" from original:`, typeof originalValue); - if (originalValue !== undefined) { // Bind methods to preserve 'this' context return typeof originalValue === 'function' ? originalValue.bind(target) : originalValue; @@ -178,15 +161,15 @@ function maybeHandlePromiseRejection( return undefined; }, + has(target, prop) { + // Check if property exists in the original object + return prop in (target as object); + }, }); } + // Non-thenable value - call callbacks immediately and return as-is onFinally(); onSuccess(value); - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const hasAbort = typeof value.abort === 'function'; - console.log('[NON-THENABLE] valuehasAbort:', hasAbort); return value; } diff --git a/packages/core/test/lib/utils/handleCallbackErrors-proxy.test.ts b/packages/core/test/lib/utils/handleCallbackErrors-proxy.test.ts index 534d4eb4085d..a50021e75d3b 100644 --- a/packages/core/test/lib/utils/handleCallbackErrors-proxy.test.ts +++ b/packages/core/test/lib/utils/handleCallbackErrors-proxy.test.ts @@ -28,11 +28,17 @@ describe('handleCallbackErrors - Proxy for thenable objects', () => { // Verify the result is thenable expect(typeof result.then).toBe('function'); - // Important: Verify extra methods are preserved via Proxy + // Important: Verify extra methods are preserved via Proxy (property access) expect(typeof result.abort).toBe('function'); expect(typeof result.status).toBe('number'); expect(typeof result.readyState).toBe('number'); + // Verify the 'in' operator works correctly (has trap) + expect('abort' in result).toBe(true); + expect('status' in result).toBe(true); + expect('readyState' in result).toBe(true); + expect('then' in result).toBe(true); + const abortResult = result.abort(); expect(abortResult).toBe('abort-successful'); expect(mockJqXHR.abort).toHaveBeenCalledTimes(1); From b4c4bbf3b9cdbb1d3bfd51c86f132cc6a561e70e Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 12 Feb 2026 09:16:32 +0100 Subject: [PATCH 10/43] remove proxy wrapping --- .../browser-integration-tests/package.json | 2 +- .../thenable-with-extra-methods/subject.js | 7 +- .../core/src/asyncContext/stackStrategy.ts | 6 +- .../core/src/utils/handleCallbackErrors.ts | 99 +++---------------- 4 files changed, 21 insertions(+), 93 deletions(-) diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index d5402aa57b2d..0ac34e683b1e 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -16,7 +16,7 @@ "postinstall": "yarn install-browsers", "pretest": "yarn clean && yarn type-check", "test": "yarn test:all --project='chromium'", - "test:all": "npx playwright test -c playwright.browser.config.ts", + "test:all": "npx playwright test -c playwright.browser.config.ts -g 'thenable'", "test:bundle": "PW_BUNDLE=bundle yarn test", "test:bundle:min": "PW_BUNDLE=bundle_min yarn test", "test:bundle:logs_metrics": "PW_BUNDLE=bundle_logs_metrics yarn test", diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/subject.js b/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/subject.js index 35d0adf50ea6..3e43239c2b5c 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/subject.js +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/subject.js @@ -47,6 +47,8 @@ async function runTest() { const hasStatus1 = 'status' in d; const hasReadyState1 = 'readyState' in d; + console.log('[AJAX CALL] jqXHR object:', Object.keys(d)); + console.log('jqXHR methods preserved:', d.readyState, { hasAbort1, hasStatus1, hasReadyState1 }); return d; @@ -56,7 +58,7 @@ async function runTest() { const hasAbort = typeof result.abort === 'function'; const hasReadyState = 'readyState' in result; - console.log('Result from startSpan:', result.toString(), Object.keys(result)); + console.log('Result object keys:', Object.keys(result)); console.log('jqXHR methods preserved:', { hasAbort, @@ -65,7 +67,7 @@ async function runTest() { abortType: typeof result.abort, }); - if (hasAbort && hasReadyState) { + if (true || (hasAbort && hasReadyState)) { // Call abort() to test it works try { result.abort(); @@ -73,6 +75,7 @@ async function runTest() { window.jqXHRAbortResult = 'abort-successful'; window.jqXHRMethodsPreserved = true; } catch (e) { + console.log('abort() threw an error:', e); window.jqXHRTestError = `abort() failed: ${e.message}`; window.jqXHRMethodsPreserved = false; } diff --git a/packages/core/src/asyncContext/stackStrategy.ts b/packages/core/src/asyncContext/stackStrategy.ts index 87dc534fc636..02e8dd374df6 100644 --- a/packages/core/src/asyncContext/stackStrategy.ts +++ b/packages/core/src/asyncContext/stackStrategy.ts @@ -52,8 +52,8 @@ export class AsyncContextStack { } if (isThenable(maybePromiseResult)) { - // @ts-expect-error - isThenable returns the wrong type - return maybePromiseResult.then( + // Attach handlers but still return original promise + maybePromiseResult.then( res => { this._popScope(); return res; @@ -63,6 +63,8 @@ export class AsyncContextStack { throw e; }, ); + + return maybePromiseResult; } this._popScope(); diff --git a/packages/core/src/utils/handleCallbackErrors.ts b/packages/core/src/utils/handleCallbackErrors.ts index e3cdd860ad0e..a8c9177c5ac0 100644 --- a/packages/core/src/utils/handleCallbackErrors.ts +++ b/packages/core/src/utils/handleCallbackErrors.ts @@ -76,96 +76,19 @@ function maybeHandlePromiseRejection( onSuccess: (result: MaybePromise | AwaitedPromise) => void, ): MaybePromise { if (isThenable(value)) { - // Track whether we've already attached handlers to avoid calling callbacks multiple times - let handlersAttached = false; - - // 1. Wrap the original thenable in a Proxy to preserve all its methods - return new Proxy(value, { - get(target, prop, receiver) { - // Special handling for .then() - intercept it to add error handling - if (prop === 'then' && typeof target.then === 'function') { - return function ( - onfulfilled?: ((value: unknown) => unknown) | null, - onrejected?: ((reason: unknown) => unknown) | null, - ) { - // Only attach handlers once to avoid calling callbacks multiple times - if (!handlersAttached) { - handlersAttached = true; - - // Wrap the fulfillment handler to call our callbacks - const wrappedOnFulfilled = onfulfilled - ? (res: unknown) => { - onFinally(); - onSuccess(res as MaybePromise); - return onfulfilled(res); - } - : (res: unknown) => { - onFinally(); - onSuccess(res as MaybePromise); - return res; - }; - - // Wrap the rejection handler to call our callbacks - const wrappedOnRejected = onrejected - ? (err: unknown) => { - onError(err); - onFinally(); - return onrejected(err); - } - : (err: unknown) => { - onError(err); - onFinally(); - throw err; - }; - - // Call the original .then() with our wrapped handlers - const thenResult = target.then.call(target, wrappedOnFulfilled, wrappedOnRejected); - - // 2. Some thenable implementations (like jQuery) return a new object from .then() - // that doesn't include custom properties from the original (like .abort()). - // We wrap the result in another Proxy to preserve access to those properties. - return new Proxy(thenResult, { - get(thenTarget, thenProp) { - // First try to get the property from the .then() result - const thenValue = Reflect.get(thenTarget, thenProp, thenTarget); - if (thenValue !== undefined) { - return typeof thenValue === 'function' ? thenValue.bind(thenTarget) : thenValue; - } - - // Fall back to the original object for properties like .abort() - const originalValue = Reflect.get(target, thenProp, target); - if (originalValue !== undefined) { - return typeof originalValue === 'function' ? originalValue.bind(target) : originalValue; - } - - return undefined; - }, - has(thenTarget, thenProp) { - // Check if property exists in either the .then() result or the original object - return thenProp in thenTarget || thenProp in (target as object); - }, - }); - } else { - // Subsequent .then() calls pass through without additional wrapping - return target.then.call(target, onfulfilled, onrejected); - } - }; - } - - // For all other properties, forward to the original object - const originalValue = Reflect.get(target, prop, target); - if (originalValue !== undefined) { - // Bind methods to preserve 'this' context - return typeof originalValue === 'function' ? originalValue.bind(target) : originalValue; - } - - return undefined; + // Attach handlers but still return original promise + value.then( + res => { + onFinally(); + onSuccess(res); }, - has(target, prop) { - // Check if property exists in the original object - return prop in (target as object); + err => { + onError(err); + onFinally(); }, - }); + ); + + return value; } // Non-thenable value - call callbacks immediately and return as-is From bd451fc3ceb7d1067b141048227451be1f2d83b0 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 12 Feb 2026 12:04:05 +0100 Subject: [PATCH 11/43] add tracing unit tests --- packages/core/test/lib/tracing/trace.test.ts | 26 ++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index 271cd669d56c..e7ea8bc8fa7e 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -210,6 +210,32 @@ describe('startSpan', () => { }); }); + describe('AsyncContext withScope promise integrity behavior', () => { + it('returns the original promise instance', async () => { + const original = Promise.resolve(42); + const result = startSpan({}, () => original); + expect(result).toBe(original); // New behavior + }); + + it('returns same instance on multiple calls', () => { + const p = Promise.resolve(1); + const result1 = startSpan({}, () => p); + const result2 = startSpan({}, () => p); + expect(result1).toBe(result2); + }); + + it('preserves custom thenable methods', async () => { + const jqXHR = { + then: Promise.resolve(1).then.bind(Promise.resolve(1)), + abort: vi.fn(), + }; + const result = startSpan({}, () => jqXHR); + expect(typeof result.abort).toBe('function'); + result.abort(); + expect(jqXHR.abort).toHaveBeenCalled(); + }); + }); + it('returns a non recording span if tracing is disabled', () => { const options = getDefaultTestClientOptions({}); client = new TestClient(options); From b978b79479084ffc0d9ea2379456339b75429617 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:35:19 +0100 Subject: [PATCH 12/43] clean up test cases --- .../thenable-with-extra-methods/subject.js | 30 +------ .../thenable-with-extra-methods/test.ts | 26 +----- .../utils/handleCallbackErrors-proxy.test.ts | 88 ------------------- 3 files changed, 6 insertions(+), 138 deletions(-) delete mode 100644 packages/core/test/lib/utils/handleCallbackErrors-proxy.test.ts diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/subject.js b/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/subject.js index 3e43239c2b5c..6a0a6c4cae5e 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/subject.js +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/subject.js @@ -2,9 +2,9 @@ * Test that verifies thenable objects with extra methods (like jQuery's jqXHR) * preserve those methods when returned from Sentry.startSpan(). * - * This tests the Proxy fix for the GitHub issue where: + * Example case: * const jqXHR = Sentry.startSpan({ name: "test" }, () => $.ajax(...)); - * jqXHR.abort(); // Should work! + * jqXHR.abort(); // Should work and not throw an error because of missing abort() method */ // Load jQuery from CDN @@ -34,41 +34,19 @@ async function runTest() { throw new Error('jQuery not loaded'); } - // Real-world test: Wrap actual jQuery $.ajax() call in startSpan const result = Sentry.startSpan({ name: 'test-jqxhr', op: 'http.client' }, () => { // Make a real AJAX request with jQuery - const d = window.jQuery.ajax({ + return window.jQuery.ajax({ url: 'https://httpbin.org/status/200', method: 'GET', timeout: 5000, }); - // Check if jqXHR methods are preserved - const hasAbort1 = typeof d.abort === 'function'; - const hasStatus1 = 'status' in d; - const hasReadyState1 = 'readyState' in d; - - console.log('[AJAX CALL] jqXHR object:', Object.keys(d)); - - console.log('jqXHR methods preserved:', d.readyState, { hasAbort1, hasStatus1, hasReadyState1 }); - - return d; }); - // Check if jqXHR methods are preserved using 'in' operator (tests has trap) const hasAbort = typeof result.abort === 'function'; const hasReadyState = 'readyState' in result; - console.log('Result object keys:', Object.keys(result)); - - console.log('jqXHR methods preserved:', { - hasAbort, - hasReadyState, - readyStateValue: result.readyState, - abortType: typeof result.abort, - }); - - if (true || (hasAbort && hasReadyState)) { - // Call abort() to test it works + if (hasAbort && hasReadyState) { try { result.abort(); window.jqXHRAbortCalled = true; diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/test.ts b/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/test.ts index c02516a20dc3..44008a93a3e9 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/test.ts @@ -2,51 +2,31 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../utils/fixtures'; import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers'; -/** - * These tests verify that thenable objects with extra methods (like jQuery's jqXHR) - * preserve those methods when returned from startSpan(). - * - * Tests the Proxy fix that allows code like this to work: - * const jqXHR = Sentry.startSpan({ name: "test" }, () => $.ajax(...)); - * jqXHR.abort(); // Now works! ✅ - */ - sentryTest('preserves extra methods on real jQuery jqXHR objects', async ({ getLocalTestUrl, page }) => { - page.on('console', msg => { - console.log(`Console log from page: ${msg.text()}`); - }); - if (shouldSkipTracingTest()) { sentryTest.skip(); } const url = await getLocalTestUrl({ testDir: __dirname }); - - // Wait for the transaction to be sent const transactionPromise = waitForTransactionRequest(page); await page.goto(url); - // Wait for jQuery to load and test to complete + // Wait for jQuery to load await page.waitForTimeout(1000); - // Verify extra methods are preserved const methodsPreserved = await page.evaluate(() => (window as any).jqXHRMethodsPreserved); expect(methodsPreserved).toBe(true); - // Verify abort() was actually called const abortCalled = await page.evaluate(() => (window as any).jqXHRAbortCalled); expect(abortCalled).toBe(true); - // Verify abort() returned successfully const abortReturnValue = await page.evaluate(() => (window as any).jqXHRAbortResult); expect(abortReturnValue).toBe('abort-successful'); - // Verify no errors occurred const testError = await page.evaluate(() => (window as any).jqXHRTestError); expect(testError).toBeNull(); - // Verify the span was created and sent const transaction = envelopeRequestParser(await transactionPromise); expect(transaction.transaction).toBe('test-jqxhr'); expect(transaction.spans).toBeDefined(); @@ -60,14 +40,12 @@ sentryTest('aborted request rejects promise correctly', async ({ getLocalTestUrl const url = await getLocalTestUrl({ testDir: __dirname }); await page.goto(url); - // Wait for jQuery to load and test to complete + // Wait for jQuery to load await page.waitForTimeout(1000); - // Verify the aborted request was rejected (not resolved) const promiseRejected = await page.evaluate(() => (window as any).jqXHRPromiseRejected); expect(promiseRejected).toBe(true); - // Should NOT have resolved const promiseResolved = await page.evaluate(() => (window as any).jqXHRPromiseResolved); expect(promiseResolved).toBe(false); }); diff --git a/packages/core/test/lib/utils/handleCallbackErrors-proxy.test.ts b/packages/core/test/lib/utils/handleCallbackErrors-proxy.test.ts deleted file mode 100644 index a50021e75d3b..000000000000 --- a/packages/core/test/lib/utils/handleCallbackErrors-proxy.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { handleCallbackErrors } from '../../../src/utils/handleCallbackErrors'; - -describe('handleCallbackErrors - Proxy for thenable objects', () => { - it('preserves extra methods on thenable objects (jQuery jqXHR use case)', async () => { - const onError = vi.fn(); - const onFinally = vi.fn(); - - // Mock a JQuery jqXHR-like object with both Promise and XHR methods - let resolvePromise: (value: unknown) => void; - const promise = new Promise(resolve => { - resolvePromise = resolve; - }); - - const mockJqXHR = { - then: promise.then.bind(promise), - catch: promise.catch.bind(promise), - abort: vi.fn(() => 'abort-successful'), - status: 0, - readyState: 1, - responseText: '', - }; - - const fn = vi.fn(() => mockJqXHR); - - const result = handleCallbackErrors(fn, onError, onFinally); - - // Verify the result is thenable - expect(typeof result.then).toBe('function'); - - // Important: Verify extra methods are preserved via Proxy (property access) - expect(typeof result.abort).toBe('function'); - expect(typeof result.status).toBe('number'); - expect(typeof result.readyState).toBe('number'); - - // Verify the 'in' operator works correctly (has trap) - expect('abort' in result).toBe(true); - expect('status' in result).toBe(true); - expect('readyState' in result).toBe(true); - expect('then' in result).toBe(true); - - const abortResult = result.abort(); - expect(abortResult).toBe('abort-successful'); - expect(mockJqXHR.abort).toHaveBeenCalledTimes(1); - - // Verify promise functionality still works - resolvePromise!({ data: 'test' }); - const promiseResult = await result; - expect(promiseResult).toEqual({ data: 'test' }); - expect(onFinally).toHaveBeenCalledTimes(1); - }); - - it('preserves method binding context', async () => { - const onError = vi.fn(); - - let resolvePromise: (value: unknown) => void; - const promise = new Promise(resolve => { - resolvePromise = resolve; - }); - - const mockJqXHR = { - then: promise.then.bind(promise), - _internalState: 'test-state', - getState: function () { - return this._internalState; - }, - }; - - const fn = vi.fn(() => mockJqXHR); - const result = handleCallbackErrors(fn, onError); - - // Verify method is bound to original object - expect(result.getState()).toBe('test-state'); - - resolvePromise!('done'); - await result; - }); - - it('does not affect non-thenable values', () => { - const onError = vi.fn(); - const fn = vi.fn(() => 'plain-value'); - - const result = handleCallbackErrors(fn, onError); - - expect(result).toBe('plain-value'); - expect(fn).toHaveBeenCalledTimes(1); - }); -}); From 7531ac4a04392c5fc25dfb15e305a7fb03ab1f0e Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:59:39 +0100 Subject: [PATCH 13/43] remove throw error --- dev-packages/browser-integration-tests/package.json | 2 +- packages/core/src/asyncContext/stackStrategy.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 0ac34e683b1e..d5402aa57b2d 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -16,7 +16,7 @@ "postinstall": "yarn install-browsers", "pretest": "yarn clean && yarn type-check", "test": "yarn test:all --project='chromium'", - "test:all": "npx playwright test -c playwright.browser.config.ts -g 'thenable'", + "test:all": "npx playwright test -c playwright.browser.config.ts", "test:bundle": "PW_BUNDLE=bundle yarn test", "test:bundle:min": "PW_BUNDLE=bundle_min yarn test", "test:bundle:logs_metrics": "PW_BUNDLE=bundle_logs_metrics yarn test", diff --git a/packages/core/src/asyncContext/stackStrategy.ts b/packages/core/src/asyncContext/stackStrategy.ts index 02e8dd374df6..da945aa46138 100644 --- a/packages/core/src/asyncContext/stackStrategy.ts +++ b/packages/core/src/asyncContext/stackStrategy.ts @@ -58,9 +58,10 @@ export class AsyncContextStack { this._popScope(); return res; }, - e => { + _e => { this._popScope(); - throw e; + // We don't re-throw the error here because the caller already receives the original rejection, and it's being handled by the caller of withScope. + // Re-throwing it here would cause unhandled promise rejections. }, ); From 5e995939522373659dd70207d3722a1f38f0e5b0 Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 13 Feb 2026 11:35:18 -0800 Subject: [PATCH 14/43] fix: Copy properties onto Sentry-chained promises Copy properties from the original promise onto the chained tracker, so that we can return something that can inspect the error, does not obscure `unhandledrejection`, *and* supports jQuery's extended PromiseLike objects. We only do this if the original PromiseLike object or its chained counterpart are not `instanceof Promise`. So far, we have not encountered such decoration on "normal" Promise objects, so this is a fast way to ensure that we're only providing this affordance where it's needed. This does introduce a limitation that if someone decorates a standard Promise object, and its decorations do not extend to chained results of `then()`, then we will lose them. If and when that happens, we can consider extending this check to something more thorough, even if it's slightly less performant. A guard is added to prevent cases where a chained/copied promise is passed through this function again, so that we know we still have to do the operation even though it's a "normal" Promise. --- .size-limit.js | 2 +- .../core/src/asyncContext/stackStrategy.ts | 20 ++----- .../src/utils/chain-and-copy-promiselike.ts | 55 ++++++++++++++++++ .../core/src/utils/handleCallbackErrors.ts | 14 ++--- packages/core/test/lib/tracing/trace.test.ts | 16 +----- .../utils/chain-and-copy-promiselike.test.ts | 56 +++++++++++++++++++ 6 files changed, 126 insertions(+), 37 deletions(-) create mode 100644 packages/core/src/utils/chain-and-copy-promiselike.ts create mode 100644 packages/core/test/lib/utils/chain-and-copy-promiselike.test.ts diff --git a/.size-limit.js b/.size-limit.js index 750e7ce8f7fd..3e0902c0a57c 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -148,7 +148,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '45 KB', + limit: '45.1 KB', }, // Vue SDK (ESM) { diff --git a/packages/core/src/asyncContext/stackStrategy.ts b/packages/core/src/asyncContext/stackStrategy.ts index da945aa46138..36c1d2127530 100644 --- a/packages/core/src/asyncContext/stackStrategy.ts +++ b/packages/core/src/asyncContext/stackStrategy.ts @@ -1,6 +1,7 @@ import type { Client } from '../client'; import { getDefaultCurrentScope, getDefaultIsolationScope } from '../defaultScopes'; import { Scope } from '../scope'; +import { chainAndCopyPromiseLike } from '../utils/chain-and-copy-promiselike'; import { isThenable } from '../utils/is'; import { getMainCarrier, getSentryCarrier } from './../carrier'; import type { AsyncContextStrategy } from './types'; @@ -52,20 +53,11 @@ export class AsyncContextStack { } if (isThenable(maybePromiseResult)) { - // Attach handlers but still return original promise - maybePromiseResult.then( - res => { - this._popScope(); - return res; - }, - _e => { - this._popScope(); - // We don't re-throw the error here because the caller already receives the original rejection, and it's being handled by the caller of withScope. - // Re-throwing it here would cause unhandled promise rejections. - }, - ); - - return maybePromiseResult; + return chainAndCopyPromiseLike( + maybePromiseResult as PromiseLike> & Record, + () => this._popScope(), + () => this._popScope(), + ) as T; } this._popScope(); diff --git a/packages/core/src/utils/chain-and-copy-promiselike.ts b/packages/core/src/utils/chain-and-copy-promiselike.ts new file mode 100644 index 000000000000..4d8db088d22e --- /dev/null +++ b/packages/core/src/utils/chain-and-copy-promiselike.ts @@ -0,0 +1,55 @@ +const isActualPromise = (p: unknown) => + p instanceof Promise && !(p as unknown as ChainedPromiseLike)[kChainedCopy]; + +type ChainedPromiseLike = PromiseLike & { + [kChainedCopy]: true; +}; +const kChainedCopy = Symbol('chained PromiseLike'); + +/** + * Copy the properties from a decorated promiselike object onto its chained + * actual promise. + */ +export const chainAndCopyPromiseLike = >( + original: T, + onSuccess: (value: V) => void, + onError: (e: unknown) => void, +): T => { + const chained = original.then( + value => { + onSuccess(value); + return value; + }, + err => { + onError(err); + throw err; + }, + ) as T; + + // if we're just dealing with "normal" Promise objects, return the chain + return isActualPromise(chained) && isActualPromise(original) ? chained : copyProps(original, chained); +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const copyProps = >(original: T, chained: T): T => { + let mutated = false; + //oxlint-disable-next-line guard-for-in + for (const key in original) { + if (key in chained) continue; + mutated = true; + const value = original[key]; + if (typeof value === 'function') { + Object.defineProperty(chained, key, { + value: (...args: unknown[]) => value.apply(original, args), + enumerable: true, + configurable: true, + writable: true, + }); + } else { + (chained as Record)[key] = value; + } + } + + if (mutated) Object.assign(chained, { [kChainedCopy]: true }); + return chained; +}; diff --git a/packages/core/src/utils/handleCallbackErrors.ts b/packages/core/src/utils/handleCallbackErrors.ts index a8c9177c5ac0..4fa0b036c101 100644 --- a/packages/core/src/utils/handleCallbackErrors.ts +++ b/packages/core/src/utils/handleCallbackErrors.ts @@ -1,3 +1,4 @@ +import { chainAndCopyPromiseLike } from '../utils/chain-and-copy-promiselike'; import { isThenable } from '../utils/is'; /* eslint-disable */ @@ -76,21 +77,18 @@ function maybeHandlePromiseRejection( onSuccess: (result: MaybePromise | AwaitedPromise) => void, ): MaybePromise { if (isThenable(value)) { - // Attach handlers but still return original promise - value.then( - res => { + return chainAndCopyPromiseLike( + value as MaybePromise & PromiseLike> & Record, + result => { onFinally(); - onSuccess(res); + onSuccess(result as Awaited); }, err => { onError(err); onFinally(); }, - ); - - return value; + ) as MaybePromise; } - // Non-thenable value - call callbacks immediately and return as-is onFinally(); onSuccess(value); diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index e7ea8bc8fa7e..6be1bf0577f3 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -211,25 +211,13 @@ describe('startSpan', () => { }); describe('AsyncContext withScope promise integrity behavior', () => { - it('returns the original promise instance', async () => { - const original = Promise.resolve(42); - const result = startSpan({}, () => original); - expect(result).toBe(original); // New behavior - }); - - it('returns same instance on multiple calls', () => { - const p = Promise.resolve(1); - const result1 = startSpan({}, () => p); - const result2 = startSpan({}, () => p); - expect(result1).toBe(result2); - }); - it('preserves custom thenable methods', async () => { const jqXHR = { then: Promise.resolve(1).then.bind(Promise.resolve(1)), abort: vi.fn(), }; - const result = startSpan({}, () => jqXHR); + expect(jqXHR instanceof Promise).toBe(false); + const result = startSpan({ name: 'test' }, () => jqXHR); expect(typeof result.abort).toBe('function'); result.abort(); expect(jqXHR.abort).toHaveBeenCalled(); diff --git a/packages/core/test/lib/utils/chain-and-copy-promiselike.test.ts b/packages/core/test/lib/utils/chain-and-copy-promiselike.test.ts new file mode 100644 index 000000000000..2f4415940dc8 --- /dev/null +++ b/packages/core/test/lib/utils/chain-and-copy-promiselike.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { chainAndCopyPromiseLike } from '../../../src/utils/chain-and-copy-promiselike'; + +describe('chain and copy promiselike objects', () => { + it('does no copying for normal promises', async () => { + const p = new Promise(res => res(1)); + Object.assign(p, { newProperty: true }); + let success = false; + let error = false; + const q = chainAndCopyPromiseLike( + p, + () => { + success = true; + }, + () => { + error = true; + }, + ); + expect(await q).toBe(1); + //@ts-expect-error - this is not a normal prop on Promises + expect(q.newProperty).toBe(undefined); + expect(success).toBe(true); + expect(error).toBe(false); + }); + + it('copies properties of non-Promise then-ables', async () => { + class FakePromise { + value: T; + constructor(value: T) { + this.value = value; + } + then(fn: (value: T) => unknown) { + const newVal = fn(this.value); + return new FakePromise(newVal); + } + } + const p = new FakePromise(1) as PromiseLike; + Object.assign(p, { newProperty: true }); + let success = false; + let error = false; + const q = chainAndCopyPromiseLike( + p, + () => { + success = true; + }, + () => { + error = true; + }, + ); + expect(await q).toBe(1); + //@ts-expect-error - this is not a normal prop on FakePromises + expect(q.newProperty).toBe(true); + expect(success).toBe(true); + expect(error).toBe(false); + }); +}); From e9bf658b9e3520a751a4515b2f365f5d55304518 Mon Sep 17 00:00:00 2001 From: isaacs Date: Thu, 19 Mar 2026 11:26:04 -0700 Subject: [PATCH 15/43] chore(lint): resolve oxlint warnings - Turn off `no-base-to-string`, as we rely on casting things to strings in many places after verifying that they are simple not-falsey. It could be worthwhile to impose better type safety on all these things at some point, but that is unlikely to be worth doing in the near term. - Remove several unused variables. We could consider disabling this rule in tests specifically, since that is where it's most common to leave scaffolding and debugging values behind. - Cast as types in a few cases to make our intention clear, where the values were actually being used safely, but not explicitly. - Disable `require-array-sort-compare` in one case where we benefit with a smaller code footprint by using the implicit numeric comparison on `Array.sort()`. --- .oxlintrc.json | 2 +- .../node-overhead-gh-action/lib/getOverheadMeasurements.mjs | 1 + packages/effect/test/layer.test.ts | 2 +- .../nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts | 1 - packages/node-core/src/integrations/systemError.ts | 2 +- packages/nuxt/src/vite/sentryVitePlugin.ts | 3 +-- packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts | 2 +- packages/remix/src/server/serverTimingTracePropagation.ts | 1 - .../remix/test/server/serverTimingTracePropagation.test.ts | 5 +++-- packages/replay-internal/src/eventBuffer/EventBufferArray.ts | 1 + 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index ef23a888fab8..e65745736664 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -66,7 +66,7 @@ "typescript/no-redundant-type-constituents": "off", "typescript/restrict-template-expressions": "off", "typescript/await-thenable": "warn", - "typescript/no-base-to-string": "warn" + "typescript/no-base-to-string": "off" } }, { diff --git a/dev-packages/node-overhead-gh-action/lib/getOverheadMeasurements.mjs b/dev-packages/node-overhead-gh-action/lib/getOverheadMeasurements.mjs index 9bc70d48d4c3..266b62cd7742 100644 --- a/dev-packages/node-overhead-gh-action/lib/getOverheadMeasurements.mjs +++ b/dev-packages/node-overhead-gh-action/lib/getOverheadMeasurements.mjs @@ -28,6 +28,7 @@ async function getMeasurements(instrumentFile, autocannonCommand = 'yarn test:ge await killAppProcess(); return result; } catch (error) { + //oxlint-disable-next-line restrict-template-expressions log(`Error running autocannon: ${error}`); await killAppProcess(); throw error; diff --git a/packages/effect/test/layer.test.ts b/packages/effect/test/layer.test.ts index 590502fb657e..1874fe9b0f53 100644 --- a/packages/effect/test/layer.test.ts +++ b/packages/effect/test/layer.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from '@effect/vitest'; import * as sentryCore from '@sentry/core'; -import { getClient, getCurrentScope, getIsolationScope, SDK_VERSION, SentrySpan } from '@sentry/core'; +import { getClient, getCurrentScope, getIsolationScope, SDK_VERSION } from '@sentry/core'; import { Effect, Layer, Logger, LogLevel } from 'effect'; import { afterEach, beforeEach, vi } from 'vitest'; import * as sentryClient from '../src/index.client'; diff --git a/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts index 31624e3bffc6..2d933373578d 100644 --- a/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts +++ b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts @@ -138,7 +138,6 @@ describe('dropMiddlewareTunnelRequests', () => { describe('skipOpenTelemetrySetup', () => { it('does not process spans when skipOpenTelemetrySetup is true', async () => { const core = await import('@sentry/core'); - const originalGetClient = core.getClient; vi.spyOn(core, 'getClient').mockReturnValueOnce({ getOptions: () => ({ skipOpenTelemetrySetup: true }), } as any); diff --git a/packages/node-core/src/integrations/systemError.ts b/packages/node-core/src/integrations/systemError.ts index f1fd3f4db0dc..14c0c23ffa54 100644 --- a/packages/node-core/src/integrations/systemError.ts +++ b/packages/node-core/src/integrations/systemError.ts @@ -46,7 +46,7 @@ export const systemErrorIntegration = defineIntegration((options: Options = {}) const error = hint.originalException; const errorContext: SystemErrorContext = { - ...error, + ...(error as SystemErrorContext), }; if (!client.getOptions().sendDefaultPii && options.includePaths !== true) { diff --git a/packages/nuxt/src/vite/sentryVitePlugin.ts b/packages/nuxt/src/vite/sentryVitePlugin.ts index f301a17c9423..31ef7c97465d 100644 --- a/packages/nuxt/src/vite/sentryVitePlugin.ts +++ b/packages/nuxt/src/vite/sentryVitePlugin.ts @@ -1,8 +1,7 @@ import type { Nuxt } from '@nuxt/schema'; -import { sentryVitePlugin } from '@sentry/vite-plugin'; import type { ConfigEnv, Plugin, UserConfig } from 'vite'; import type { SentryNuxtModuleOptions } from '../common/types'; -import { extractNuxtSourceMapSetting, getPluginOptions, validateDifferentSourceMapSettings } from './sourceMaps'; +import { extractNuxtSourceMapSetting, validateDifferentSourceMapSettings } from './sourceMaps'; /** * Creates a Vite plugin that adds the Sentry Vite plugin and validates source map settings. diff --git a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts index be33cf500397..e0b4956219c3 100644 --- a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts +++ b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts @@ -112,7 +112,7 @@ describe('setupSourceMaps hooks', () => { it.each([ { label: 'server (SSR) build', buildConfig: { build: { ssr: true }, plugins: [] } }, { label: 'client build', buildConfig: { build: { ssr: false }, plugins: [] } }, - ])('adds sentry vite plugin to vite config for $label in production', async ({ buildConfig }) => { + ])('adds sentry vite plugin to vite config for $label in production', async () => { const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); const mockNuxt = createMockNuxt({ _prepare: false, dev: false }); const { mockAddVitePlugin, getCapturedPlugins } = createMockAddVitePlugin(); diff --git a/packages/remix/src/server/serverTimingTracePropagation.ts b/packages/remix/src/server/serverTimingTracePropagation.ts index fd8440f3578d..d9197c3315bb 100644 --- a/packages/remix/src/server/serverTimingTracePropagation.ts +++ b/packages/remix/src/server/serverTimingTracePropagation.ts @@ -1,4 +1,3 @@ -import type { Span } from '@sentry/core'; import { debug, getTraceData, isNodeEnv } from '@sentry/core'; import { DEBUG_BUILD } from '../utils/debug-build'; import { isCloudflareEnv } from '../utils/utils'; diff --git a/packages/remix/test/server/serverTimingTracePropagation.test.ts b/packages/remix/test/server/serverTimingTracePropagation.test.ts index 7e9852e97c6b..0543146277df 100644 --- a/packages/remix/test/server/serverTimingTracePropagation.test.ts +++ b/packages/remix/test/server/serverTimingTracePropagation.test.ts @@ -1,14 +1,15 @@ -import { getActiveSpan, getTraceData, isNodeEnv, spanToBaggageHeader, spanToTraceHeader } from '@sentry/core'; +import { getActiveSpan, getTraceData, isNodeEnv } from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { generateSentryServerTimingHeader, injectServerTimingHeaderValue, } from '../../src/server/serverTimingTracePropagation'; +import type { Span } from '@sentry/core'; const mockSpan = { spanId: 'test-span-id', spanContext: () => ({ traceId: '12345678901234567890123456789012' }), -}; +} as unknown as Span; const mockRootSpan = { spanId: 'root-span-id', spanContext: () => ({ traceId: '12345678901234567890123456789012' }), diff --git a/packages/replay-internal/src/eventBuffer/EventBufferArray.ts b/packages/replay-internal/src/eventBuffer/EventBufferArray.ts index 9b6f5f602e63..f9cf70c8f42c 100644 --- a/packages/replay-internal/src/eventBuffer/EventBufferArray.ts +++ b/packages/replay-internal/src/eventBuffer/EventBufferArray.ts @@ -73,6 +73,7 @@ export class EventBufferArray implements EventBuffer { /** @inheritdoc */ public getEarliestTimestamp(): number | null { + //oxlint-disable-next-line require-array-sort-compare const timestamp = this.events.map(event => event.timestamp).sort()[0]; if (!timestamp) { From c167cf2ec2ec1e17b4e61f0dfbe014b2bb63992e Mon Sep 17 00:00:00 2001 From: isaacs Date: Thu, 19 Mar 2026 12:06:01 -0700 Subject: [PATCH 16/43] chore: avoid unnecessary sort to get smallest value This changes getEarliestTimestamp() to walk the list a single time, instead of performing a full sort, as a sorted list is not needed, just the smallest value. --- .../src/eventBuffer/EventBufferArray.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/replay-internal/src/eventBuffer/EventBufferArray.ts b/packages/replay-internal/src/eventBuffer/EventBufferArray.ts index f9cf70c8f42c..cb6c0d49c528 100644 --- a/packages/replay-internal/src/eventBuffer/EventBufferArray.ts +++ b/packages/replay-internal/src/eventBuffer/EventBufferArray.ts @@ -73,13 +73,10 @@ export class EventBufferArray implements EventBuffer { /** @inheritdoc */ public getEarliestTimestamp(): number | null { - //oxlint-disable-next-line require-array-sort-compare - const timestamp = this.events.map(event => event.timestamp).sort()[0]; - - if (!timestamp) { - return null; + let ts: number | null = null; + for (const { timestamp } of this.events) { + if (ts === null || timestamp < ts) ts = timestamp; } - - return timestampToMs(timestamp); + return ts === null ? ts : timestampToMs(ts); } } From ddca82acb18161f517e08be286f0614e82adcd6e Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 20 Mar 2026 11:10:33 +0100 Subject: [PATCH 17/43] fix(cloudflare): Forward `ctx` argument to `Workflow.do` user callback (#19891) This PR fixes a bug in our Cloudflare Workflows instrumentation where we didn't forward the [recently introduced](https://developers.cloudflare.com/changelog/post/2026-03-06-step-context-available/) `ctx` argument to users' `Workflow.do` callbacks. We now pass all `...args` from the workflow through our instrumentation to the user callback. closes https://github.com/getsentry/sentry-javascript/issues/19883 --- packages/cloudflare/src/workflows.ts | 15 +++++++----- packages/cloudflare/test/workflow.test.ts | 30 ++++++++++++++++++++--- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/packages/cloudflare/src/workflows.ts b/packages/cloudflare/src/workflows.ts index 3c40c86ff867..6515a330ca99 100644 --- a/packages/cloudflare/src/workflows.ts +++ b/packages/cloudflare/src/workflows.ts @@ -67,24 +67,27 @@ class WrappedWorkflowStep implements WorkflowStep { private _step: WorkflowStep, ) {} - public async do>(name: string, callback: () => Promise): Promise; + public async do>( + name: string, + callback: (...args: unknown[]) => Promise, + ): Promise; public async do>( name: string, config: WorkflowStepConfig, - callback: () => Promise, + callback: (...args: unknown[]) => Promise, ): Promise; public async do>( name: string, configOrCallback: WorkflowStepConfig | (() => Promise), - maybeCallback?: () => Promise, + maybeCallback?: (...args: unknown[]) => Promise, ): Promise { // Capture the current scope, so parent span (e.g., a startSpan surrounding step.do) is preserved const scopeForStep = getCurrentScope(); - const userCallback = (maybeCallback || configOrCallback) as () => Promise; + const userCallback = (maybeCallback || configOrCallback) as (...args: unknown[]) => Promise; const config = typeof configOrCallback === 'function' ? undefined : configOrCallback; - const instrumentedCallback: () => Promise = async () => { + const instrumentedCallback = async (...args: unknown[]): Promise => { return startSpan( { op: 'function.step.do', @@ -101,7 +104,7 @@ class WrappedWorkflowStep implements WorkflowStep { }, async span => { try { - const result = await userCallback(); + const result = await userCallback(...args); span.setStatus({ code: 1 }); return result; } catch (error) { diff --git a/packages/cloudflare/test/workflow.test.ts b/packages/cloudflare/test/workflow.test.ts index b460e6bfee5a..f21bee8612a8 100644 --- a/packages/cloudflare/test/workflow.test.ts +++ b/packages/cloudflare/test/workflow.test.ts @@ -6,14 +6,16 @@ import { deterministicTraceIdFromInstanceId, instrumentWorkflowWithSentry } from const NODE_MAJOR_VERSION = parseInt(process.versions.node.split('.')[0]!); +const MOCK_STEP_CTX = { attempt: 1 }; + const mockStep: WorkflowStep = { do: vi .fn() .mockImplementation( async ( _name: string, - configOrCallback: WorkflowStepConfig | (() => Promise), - maybeCallback?: () => Promise, + configOrCallback: WorkflowStepConfig | ((...args: unknown[]) => Promise), + maybeCallback?: (...args: unknown[]) => Promise, ) => { let count = 0; @@ -22,9 +24,9 @@ const mockStep: WorkflowStep = { try { if (typeof configOrCallback === 'function') { - return await configOrCallback(); + return await configOrCallback(MOCK_STEP_CTX); } else { - return await (maybeCallback ? maybeCallback() : Promise.resolve()); + return await (maybeCallback ? maybeCallback(MOCK_STEP_CTX) : Promise.resolve()); } } catch { await new Promise(resolve => setTimeout(resolve, 1000)); @@ -427,6 +429,26 @@ describe.skipIf(NODE_MAJOR_VERSION < 20)('workflows', () => { ]); }); + test('Forwards step context (ctx) to user callback', async () => { + const callbackSpy = vi.fn().mockResolvedValue({ ok: true }); + + class CtxTestWorkflow { + constructor(_ctx: ExecutionContext, _env: unknown) {} + + async run(_event: Readonly>, step: WorkflowStep): Promise { + await step.do('ctx step', callbackSpy); + } + } + + const TestWorkflowInstrumented = instrumentWorkflowWithSentry(getSentryOptions, CtxTestWorkflow as any); + const workflow = new TestWorkflowInstrumented(mockContext, {}) as CtxTestWorkflow; + const event = { payload: {}, timestamp: new Date(), instanceId: INSTANCE_ID }; + await workflow.run(event, mockStep); + + expect(callbackSpy).toHaveBeenCalledTimes(1); + expect(callbackSpy).toHaveBeenCalledWith(MOCK_STEP_CTX); + }); + test('Step.do span becomes child of surrounding custom span', async () => { class ParentChildWorkflow { constructor(_ctx: ExecutionContext, _env: unknown) {} From c1a7b2297fc41bea75d145783946c779ed2f12f0 Mon Sep 17 00:00:00 2001 From: Roli Bosch Date: Fri, 20 Mar 2026 07:16:42 -0400 Subject: [PATCH 18/43] ref(sveltekit): Replace recast + @babel/parser with acorn (#19533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces `recast` and `@babel/parser` with `acorn` and `@sveltejs/acorn-typescript` for AST parsing in the sveltekit auto-instrumentation plugin. - [x] If you've added code that should be tested, please add tests. - [x] Ensure your code lints and the test suite passes (`yarn lint`) & (`yarn test`). - [ ] Link an issue if there is one related to your pull request. Ref #19447 ## What this does The `autoInstrument` Vite plugin parses SvelteKit `+page.ts` / `+layout.server.ts` files to detect `export const load` or `export function load` declarations. It only **reads** the AST — never transforms or prints it — so recast's source-preserving round-trip feature is entirely unused. This replaces the full recast + @babel/parser pipeline with acorn (Node.js's own parser) and `@sveltejs/acorn-typescript` (the actively maintained TypeScript plugin used by SvelteKit itself). ### Changes - **Delete** `recastTypescriptParser.ts` — the 91-line Babel parser config with 20+ plugin declarations is no longer needed - **Replace** `recast.parse()` with `acorn.Parser.extend(tsPlugin()).parse()` in `autoInstrument.ts` - **Fix** AST node type: `StringLiteral` (Babel-specific) → `Literal` (ESTree standard) for string literal export detection - **Fix** AST structure: `ast.program` (recast's File wrapper) → direct `ast` (acorn returns Program) - **Improve** error handling: `try/catch` around parse (acorn throws SyntaxError) replaces dubious null check - **Improve** type safety: remove unsafe double-cast `(specifier.exported as unknown as t.StringLiteral)` in favor of proper discriminant narrowing ### Dependency reduction **Removed:** `recast`, `ast-types`, `esprima`, `source-map`, `tslib`, `tiny-invariant`, `@babel/parser`, `@babel/types`, `@babel/helper-string-parser`, `@babel/helper-validator-identifier` **Added:** `acorn` (already in dep tree via Vite/Rollup), `@sveltejs/acorn-typescript` (already in dep tree via `@sveltejs/kit`) Net effect: ~8 fewer transitive dependencies, zero new packages for end users. ### Why @sveltejs/acorn-typescript? The original `acorn-typescript` package by TyrealHu hasn't been updated since January 2024 and has known unpatched bugs. The Svelte team's fork (`@sveltejs/acorn-typescript`) is actively maintained and is already a transitive dependency of every SvelteKit project. All 69 existing tests pass without modification. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: JPeer264 Co-authored-by: Lukas Stracke --- packages/sveltekit/package.json | 5 +- packages/sveltekit/src/vite/autoInstrument.ts | 34 +++---- .../src/vite/recastTypescriptParser.ts | 91 ------------------- yarn.lock | 42 ++++----- 4 files changed, 37 insertions(+), 135 deletions(-) delete mode 100644 packages/sveltekit/src/vite/recastTypescriptParser.ts diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index 8b92f0330875..2f54131a2a3e 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -47,18 +47,17 @@ } }, "dependencies": { - "@babel/parser": "7.26.9", "@sentry/cloudflare": "10.45.0", "@sentry/core": "10.45.0", "@sentry/node": "10.45.0", "@sentry/svelte": "10.45.0", "@sentry/vite-plugin": "^5.1.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "acorn": "^8.14.0", "magic-string": "~0.30.0", - "recast": "0.23.11", "sorcery": "1.0.0" }, "devDependencies": { - "@babel/types": "^7.26.3", "@sveltejs/kit": "^2.53.3", "@sveltejs/vite-plugin-svelte": "^3.0.0", "svelte": "^4.2.8", diff --git a/packages/sveltekit/src/vite/autoInstrument.ts b/packages/sveltekit/src/vite/autoInstrument.ts index 58862e452ddc..2a1301ce00d9 100644 --- a/packages/sveltekit/src/vite/autoInstrument.ts +++ b/packages/sveltekit/src/vite/autoInstrument.ts @@ -1,10 +1,11 @@ +import { tsPlugin } from '@sveltejs/acorn-typescript'; +import * as acorn from 'acorn'; import * as fs from 'fs'; import * as path from 'path'; -import * as recast from 'recast'; import type { Plugin } from 'vite'; import { WRAPPED_MODULE_SUFFIX } from '../common/utils'; -import { parser } from './recastTypescriptParser'; -import t = recast.types.namedTypes; + +const AcornParser = acorn.Parser.extend(tsPlugin()); export type AutoInstrumentSelection = { /** @@ -123,23 +124,21 @@ export async function canWrapLoad(id: string, debug: boolean): Promise const code = (await fs.promises.readFile(id, 'utf8')).toString(); - const ast = recast.parse(code, { - parser, - }); - - const program = (ast as { program?: t.Program }).program; - - if (!program) { + let program: acorn.Program; + try { + program = AcornParser.parse(code, { + sourceType: 'module', + ecmaVersion: 'latest', + locations: true, + }); + } catch { // eslint-disable-next-line no-console debug && console.log(`Skipping wrapping ${id} because it doesn't contain valid JavaScript or TypeScript`); return false; } const hasLoadDeclaration = program.body - .filter( - (statement): statement is recast.types.namedTypes.ExportNamedDeclaration => - statement.type === 'ExportNamedDeclaration', - ) + .filter((statement): statement is acorn.ExportNamedDeclaration => statement.type === 'ExportNamedDeclaration') .find(exportDecl => { // find `export const load = ...` if (exportDecl.declaration?.type === 'VariableDeclaration') { @@ -160,11 +159,8 @@ export async function canWrapLoad(id: string, debug: boolean): Promise return exportDecl.specifiers.find(specifier => { return ( (specifier.exported.type === 'Identifier' && specifier.exported.name === 'load') || - // Type casting here because somehow the 'exportExtensions' plugin isn't reflected in the possible types - // This plugin adds support for exporting something as a string literal (see comment above) - // Doing this to avoid adding another babel plugin dependency - ((specifier.exported.type as 'StringLiteral' | '') === 'StringLiteral' && - (specifier.exported as unknown as t.StringLiteral).value === 'load') + // ESTree/acorn represents `export { x as "load" }` with a Literal node (not Babel's StringLiteral) + (specifier.exported.type === 'Literal' && specifier.exported.value === 'load') ); }); } diff --git a/packages/sveltekit/src/vite/recastTypescriptParser.ts b/packages/sveltekit/src/vite/recastTypescriptParser.ts deleted file mode 100644 index ca37439ddae9..000000000000 --- a/packages/sveltekit/src/vite/recastTypescriptParser.ts +++ /dev/null @@ -1,91 +0,0 @@ -// This babel parser config is taken from recast's typescript parser config, specifically from these two files: -// see: https://github.com/benjamn/recast/blob/master/parsers/_babel_options.ts -// see: https://github.com/benjamn/recast/blob/master/parsers/babel-ts.ts -// -// Changes: -// - we don't add the 'jsx' plugin, to correctly parse TypeScript angle bracket type assertions -// (see https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#type-assertions) -// - minor import and export changes -// - merged the two files linked above into one for simplicity - -// Date of access: 2025-03-04 -// Commit: https://github.com/benjamn/recast/commit/ba5132174894b496285da9d001f1f2524ceaed3a - -// Recast license: - -// Copyright (c) 2012 Ben Newman - -// Permission is hereby granted, free of charge, to any person obtaining -// a copy of this software and associated documentation files (the -// "Software"), to deal in the Software without restriction, including -// without limitation the rights to use, copy, modify, merge, publish, -// distribute, sublicense, and/or sell copies of the Software, and to -// permit persons to whom the Software is furnished to do so, subject to -// the following conditions: - -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -import type { ParserPlugin } from '@babel/parser'; -import { parse as babelParse } from '@babel/parser'; -import type { Options } from 'recast'; - -export const parser: Options['parser'] = { - parse: (source: string) => - babelParse(source, { - strictMode: false, - allowImportExportEverywhere: true, - allowReturnOutsideFunction: true, - startLine: 1, - tokens: true, - plugins: [ - 'typescript', - 'asyncGenerators', - 'bigInt', - 'classPrivateMethods', - 'classPrivateProperties', - 'classProperties', - 'classStaticBlock', - 'decimal', - 'decorators-legacy', - 'doExpressions', - 'dynamicImport', - 'exportDefaultFrom', - 'exportNamespaceFrom', - 'functionBind', - 'functionSent', - 'importAssertions', - 'exportExtensions' as ParserPlugin, - 'importMeta', - 'nullishCoalescingOperator', - 'numericSeparator', - 'objectRestSpread', - 'optionalCatchBinding', - 'optionalChaining', - [ - 'pipelineOperator', - { - proposal: 'minimal', - }, - ], - [ - 'recordAndTuple', - { - syntaxType: 'hash', - }, - ], - 'throwExpressions', - 'topLevelAwait', - 'v8intrinsic', - ], - sourceType: 'module', - }), -}; diff --git a/yarn.lock b/yarn.lock index 45bc20340cb7..c2d577870a7d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1914,13 +1914,6 @@ "@babel/template" "^7.28.6" "@babel/types" "^7.28.6" -"@babel/parser@7.26.9": - version "7.26.9" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.26.9.tgz#d9e78bee6dc80f9efd8f2349dcfbbcdace280fd5" - integrity sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A== - dependencies: - "@babel/types" "^7.26.9" - "@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.16.4", "@babel/parser@^7.18.10", "@babel/parser@^7.20.7", "@babel/parser@^7.22.10", "@babel/parser@^7.22.16", "@babel/parser@^7.23.5", "@babel/parser@^7.23.6", "@babel/parser@^7.25.4", "@babel/parser@^7.26.7", "@babel/parser@^7.27.7", "@babel/parser@^7.28.0", "@babel/parser@^7.28.4", "@babel/parser@^7.28.5", "@babel/parser@^7.28.6", "@babel/parser@^7.29.0", "@babel/parser@^7.4.5", "@babel/parser@^7.7.0": version "7.29.0" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.0.tgz#669ef345add7d057e92b7ed15f0bac07611831b6" @@ -2981,7 +2974,7 @@ "@babel/types" "^7.29.0" debug "^4.3.1" -"@babel/types@^7.0.0", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.20.7", "@babel/types@^7.22.10", "@babel/types@^7.22.15", "@babel/types@^7.22.17", "@babel/types@^7.22.19", "@babel/types@^7.23.6", "@babel/types@^7.24.7", "@babel/types@^7.25.4", "@babel/types@^7.26.3", "@babel/types@^7.26.8", "@babel/types@^7.26.9", "@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.27.7", "@babel/types@^7.28.2", "@babel/types@^7.28.5", "@babel/types@^7.28.6", "@babel/types@^7.29.0", "@babel/types@^7.3.0", "@babel/types@^7.4.4", "@babel/types@^7.7.0", "@babel/types@^7.7.2": +"@babel/types@^7.0.0", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.20.7", "@babel/types@^7.22.10", "@babel/types@^7.22.15", "@babel/types@^7.22.17", "@babel/types@^7.22.19", "@babel/types@^7.23.6", "@babel/types@^7.24.7", "@babel/types@^7.25.4", "@babel/types@^7.26.8", "@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.27.7", "@babel/types@^7.28.2", "@babel/types@^7.28.5", "@babel/types@^7.28.6", "@babel/types@^7.29.0", "@babel/types@^7.3.0", "@babel/types@^7.4.4", "@babel/types@^7.7.0", "@babel/types@^7.7.2": version "7.29.0" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.0.tgz#9f5b1e838c446e72cf3cd4b918152b8c605e37c7" integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A== @@ -8652,9 +8645,14 @@ "@supabase/storage-js" "2.7.1" "@sveltejs/acorn-typescript@^1.0.5": - version "1.0.8" - resolved "https://registry.yarnpkg.com/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz#69c746a7c232094c117c50dedbd1279fc64887b7" - integrity sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA== + version "1.0.9" + resolved "https://registry.yarnpkg.com/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz#ac0bde368d6623727b0e0bc568cf6b4e5d5c4baa" + integrity sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA== + +"@sveltejs/acorn-typescript@^1.0.9": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz#ac0bde368d6623727b0e0bc568cf6b4e5d5c4baa" + integrity sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA== "@sveltejs/kit@^2.53.3": version "2.53.3" @@ -25831,17 +25829,6 @@ real-require@^0.2.0: resolved "https://registry.yarnpkg.com/real-require/-/real-require-0.2.0.tgz#209632dea1810be2ae063a6ac084fee7e33fba78" integrity sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg== -recast@0.23.11, recast@^0.23.4: - version "0.23.11" - resolved "https://registry.yarnpkg.com/recast/-/recast-0.23.11.tgz#8885570bb28cf773ba1dc600da7f502f7883f73f" - integrity sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA== - dependencies: - ast-types "^0.16.1" - esprima "~4.0.0" - source-map "~0.6.1" - tiny-invariant "^1.3.3" - tslib "^2.0.1" - recast@^0.18.1: version "0.18.10" resolved "https://registry.yarnpkg.com/recast/-/recast-0.18.10.tgz#605ebbe621511eb89b6356a7e224bff66ed91478" @@ -25862,6 +25849,17 @@ recast@^0.20.5: source-map "~0.6.1" tslib "^2.0.1" +recast@^0.23.4: + version "0.23.11" + resolved "https://registry.yarnpkg.com/recast/-/recast-0.23.11.tgz#8885570bb28cf773ba1dc600da7f502f7883f73f" + integrity sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA== + dependencies: + ast-types "^0.16.1" + esprima "~4.0.0" + source-map "~0.6.1" + tiny-invariant "^1.3.3" + tslib "^2.0.1" + rechoir@^0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" From 587cc6ca6b85f6a0ccc97fffc44d867533dcd3d7 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 20 Mar 2026 12:43:36 +0100 Subject: [PATCH 19/43] fix(core): Do not overwrite user provided conversation id in Vercel (#19903) We set the conversation id unconditionally based on the `resonse_id`, so if a user calls `Sentry.setConversationId()` explicitly this will be overwritten, which is unexpected. Closes #19904 (added automatically) --- .../vercelai/scenario-conversation-id.mjs | 27 +++++++++++++++++++ .../suites/tracing/vercelai/test.ts | 27 +++++++++++++++++++ packages/core/src/tracing/vercel-ai/index.ts | 4 ++- 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-conversation-id.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-conversation-id.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-conversation-id.mjs new file mode 100644 index 000000000000..4933d3bfb9c2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-conversation-id.mjs @@ -0,0 +1,27 @@ +import * as Sentry from '@sentry/node'; +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; + +Sentry.setConversationId('conv-a'); + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + await generateText({ + experimental_telemetry: { isEnabled: true }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'Hello!', + providerMetadata: { + openai: { responseId: 'resp_should_not_overwrite' }, + }, + }), + }), + prompt: 'Say hello', + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts index 7907544a6d11..6b0b0a45fcf8 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts @@ -923,4 +923,31 @@ describe('Vercel AI integration', () => { await createRunner().expect({ transaction: expectedTransaction }).start().completed(); }); }); + + createEsmAndCjsTests(__dirname, 'scenario-conversation-id.mjs', 'instrument.mjs', (createRunner, test) => { + test('does not overwrite conversation id set via Sentry.setConversationId with responseId from provider metadata', async () => { + await createRunner() + .expect({ + transaction: { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + op: 'gen_ai.invoke_agent', + data: expect.objectContaining({ + 'gen_ai.conversation.id': 'conv-a', + }), + }), + expect.objectContaining({ + op: 'gen_ai.generate_text', + data: expect.objectContaining({ + 'gen_ai.conversation.id': 'conv-a', + }), + }), + ]), + }, + }) + .start() + .completed(); + }); + }); }); diff --git a/packages/core/src/tracing/vercel-ai/index.ts b/packages/core/src/tracing/vercel-ai/index.ts index 17e26b7b6bac..1d8a27e8e3aa 100644 --- a/packages/core/src/tracing/vercel-ai/index.ts +++ b/packages/core/src/tracing/vercel-ai/index.ts @@ -470,7 +470,9 @@ function addProviderMetadataToAttributes(attributes: SpanAttributes): void { 'gen_ai.usage.output_tokens.prediction_rejected', openaiMetadata.rejectedPredictionTokens, ); - setAttributeIfDefined(attributes, 'gen_ai.conversation.id', openaiMetadata.responseId); + if (!attributes['gen_ai.conversation.id']) { + setAttributeIfDefined(attributes, 'gen_ai.conversation.id', openaiMetadata.responseId); + } } if (providerMetadataObject.anthropic) { From 344b774ae7a3cfd401534b955f310ffcfd06c0fa Mon Sep 17 00:00:00 2001 From: "javascript-sdk-gitflow[bot]" <255134079+javascript-sdk-gitflow[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:46:57 +0100 Subject: [PATCH 20/43] chore: Add external contributor to CHANGELOG.md (#19909) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #19533 Co-authored-by: Lms24 <8420481+Lms24@users.noreply.github.com> --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb998b2deffc..b7ecf0c40c1a 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 @roli-lpci. Thank you for your contribution! + ## 10.45.0 ### Important Changes From ebcb517dfad69f66102b8e804642d08e6f24c24b Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 20 Mar 2026 12:58:24 +0100 Subject: [PATCH 21/43] chore(ci): Fix "Gatbsy" typo in issue package label workflow (#19905) The workflow that auto-labels issues based on SDK package name had a typo mapping @sentry/gatsby to "Gatbsy" instead of "Gatsby". Closes #19906 (added automatically) Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/issue-package-label.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/issue-package-label.yml b/.github/workflows/issue-package-label.yml index ef0f0344b8fc..323b17219b1a 100644 --- a/.github/workflows/issue-package-label.yml +++ b/.github/workflows/issue-package-label.yml @@ -57,7 +57,7 @@ jobs: "label": "Ember" }, "@sentry.gatsby": { - "label": "Gatbsy" + "label": "Gatsby" }, "@sentry.google-cloud-serverless": { "label": "Google Cloud Functions" From 0d4feb25095ba5a8fbce3240f1dc3586f0dbaa68 Mon Sep 17 00:00:00 2001 From: Bobby Carp Date: Fri, 20 Mar 2026 05:55:10 -0700 Subject: [PATCH 22/43] fix(craft): Add missing mainDocsUrl for @sentry/effect SDK (#19860) ## Problem The @sentry/effect SDK was added to the release registry without the required `mainDocsUrl` field in the craft configuration. This causes a KeyError in the Sentry backend when trying to access SDK documentation URLs. **Sentry Issue**: [SENTRY-5M6R](https://sentry.sentry.io/issues/7341944871/) - **Error**: `KeyError: 'main_docs_url'` - **Impact**: 238,811 occurrences affecting 3,994 users - **First seen**: March 17, 2026 at 16:50 UTC (shortly after Effect SDK was released at 16:44 UTC) ## Solution This PR adds the missing `mainDocsUrl` field to the Effect SDK configuration in `.craft.yml`, following the same pattern as other JavaScript SDKs. ## Changes - Added `mainDocsUrl: 'https://docs.sentry.io/platforms/javascript/guides/effect/'` to the Effect SDK registry configuration ## Testing - [ ] Verified the URL format matches other JavaScript SDK documentation URLs - [ ] Confirmed the field name matches the registry specification ## Impact - Future releases of @sentry/effect will include the proper documentation URL in the release registry - This prevents the KeyError from occurring in the Sentry backend Fixes SENTRY-5M6R --- .craft.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.craft.yml b/.craft.yml index dd6f1a7f3453..57b3224d654e 100644 --- a/.craft.yml +++ b/.craft.yml @@ -245,4 +245,5 @@ targets: name: 'Sentry Effect SDK' sdkName: 'sentry.javascript.effect' packageUrl: 'https://www.npmjs.com/package/@sentry/effect' + mainDocsUrl: 'https://docs.sentry.io/platforms/javascript/guides/effect/' onlyIfPresent: /^sentry-effect-\d.*\.tgz$/ From 94cb94f083defc74a3eb2929701566f5a6e42526 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 20 Mar 2026 14:55:28 +0100 Subject: [PATCH 23/43] fix(deps): bump next to 15.5.14 in nextjs-15 and nextjs-15-intl E2E test apps (#19917) Fixes Dependabot alerts #1227 and #1228. Patches CVE-2026-27980 (unbounded next/image disk cache growth). Co-authored-by: Claude Sonnet 4.6 --- .../e2e-tests/test-applications/nextjs-15-intl/package.json | 2 +- dev-packages/e2e-tests/test-applications/nextjs-15/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json index 9e18defda67b..ca609897ff4c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json @@ -15,7 +15,7 @@ "@types/node": "^18.19.1", "@types/react": "18.0.26", "@types/react-dom": "18.0.9", - "next": "15.5.13", + "next": "15.5.14", "next-intl": "^4.3.12", "react": "latest", "react-dom": "latest", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json index fc876dc41ba7..9263605b5672 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json @@ -20,7 +20,7 @@ "@types/react": "18.0.26", "@types/react-dom": "18.0.9", "ai": "^3.0.0", - "next": "15.5.13", + "next": "15.5.14", "react": "latest", "react-dom": "latest", "typescript": "~5.0.0", From ce11ba2c972046344734e7a87e3b78691fd9097a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:46:59 +0100 Subject: [PATCH 24/43] chore(deps-dev): bump @react-router/node from 7.13.0 to 7.13.1 (#19544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@react-router/node](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-node) from 7.13.0 to 7.13.1.
Release notes

Sourced from @​react-router/node's releases.

v7.13.1

See the changelog for release notes: https://github.com/remix-run/react-router/blob/main/CHANGELOG.md#v7131

Changelog

Sourced from @​react-router/node's changelog.

7.13.1

Patch Changes

  • Updated dependencies:
    • react-router@7.13.1
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@react-router/node&package-manager=npm_and_yarn&previous-version=7.13.0&new-version=7.13.1)](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 show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/react-router/package.json | 2 +- yarn.lock | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 22175d6f4bca..626c0eeb1240 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -59,7 +59,7 @@ }, "devDependencies": { "@react-router/dev": "^7.13.0", - "@react-router/node": "^7.13.0", + "@react-router/node": "^7.13.1", "react": "^18.3.1", "react-router": "^7.13.0", "vite": "^6.1.0" diff --git a/yarn.lock b/yarn.lock index c2d577870a7d..044fd47e22b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7134,13 +7134,20 @@ valibot "^1.2.0" vite-node "^3.2.2" -"@react-router/node@7.13.0", "@react-router/node@^7.13.0": +"@react-router/node@7.13.0": version "7.13.0" resolved "https://registry.yarnpkg.com/@react-router/node/-/node-7.13.0.tgz#8146a95ab894a0035702e2f65cd069e834e25488" integrity sha512-Mhr3fAou19oc/S93tKMIBHwCPfqLpWyWM/m0NWd3pJh/wZin8/9KhAdjwxhYbXw1TrTBZBLDENa35uZ+Y7oh3A== dependencies: "@mjackson/node-fetch-server" "^0.2.0" +"@react-router/node@^7.13.1": + version "7.13.1" + resolved "https://registry.yarnpkg.com/@react-router/node/-/node-7.13.1.tgz#aadedf2fb37afe63b9ac268d818e5eec4f5437ab" + integrity sha512-IWPPf+Q3nJ6q4bwyTf5leeGUfg8GAxSN1RKj5wp9SK915zKK+1u4TCOfOmr8hmC6IW1fcjKV0WChkM0HkReIiw== + dependencies: + "@mjackson/node-fetch-server" "^0.2.0" + "@redis/bloom@1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@redis/bloom/-/bloom-1.2.0.tgz#d3fd6d3c0af3ef92f26767b56414a370c7b63b71" @@ -8644,12 +8651,7 @@ "@supabase/realtime-js" "2.11.2" "@supabase/storage-js" "2.7.1" -"@sveltejs/acorn-typescript@^1.0.5": - version "1.0.9" - resolved "https://registry.yarnpkg.com/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz#ac0bde368d6623727b0e0bc568cf6b4e5d5c4baa" - integrity sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA== - -"@sveltejs/acorn-typescript@^1.0.9": +"@sveltejs/acorn-typescript@^1.0.5", "@sveltejs/acorn-typescript@^1.0.9": version "1.0.9" resolved "https://registry.yarnpkg.com/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz#ac0bde368d6623727b0e0bc568cf6b4e5d5c4baa" integrity sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA== From cac4c466dbc8e393c97472884fade4bdbe8e858b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:47:33 +0100 Subject: [PATCH 25/43] chore(deps-dev): bump qunit-dom from 3.2.1 to 3.5.0 (#19546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [qunit-dom](https://github.com/mainmatter/qunit-dom) from 3.2.1 to 3.5.0.
Release notes

Sourced from qunit-dom's releases.

v3.5.0-qunit-dom

Release (2025-08-19)

qunit-dom 3.5.0 (minor)

:rocket: Enhancement

:house: Internal

Committers: 2

v3.4.0-qunit-dom

Release (2024-12-01)

qunit-dom 3.4.0 (minor)

:rocket: Enhancement

  • qunit-dom
    • #2153 feature: add includesValue/doesNotIncludeValue (@​CvX)

Committers: 1

v3.3.0-qunit-dom

Release (2024-11-10)

qunit-dom 3.3.0 (minor)

:rocket: Enhancement

  • qunit-dom

Committers: 1

Changelog

Sourced from qunit-dom's changelog.

Changelog

Release (2025-08-19)

qunit-dom 3.5.0 (minor)

:rocket: Enhancement

:house: Internal

Committers: 2

Release (2024-12-01)

qunit-dom 3.4.0 (minor)

:rocket: Enhancement

  • qunit-dom
    • #2153 feature: add includesValue/doesNotIncludeValue (@​CvX)

Committers: 1

Release (2024-11-10)

qunit-dom 3.3.0 (minor)

:rocket: Enhancement

  • qunit-dom

Committers: 1

Release (2024-09-23)

qunit-dom 3.2.1 (patch)

:memo: Documentation

:house: Internal

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=qunit-dom&package-manager=npm_and_yarn&previous-version=3.2.1&new-version=3.5.0)](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 show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/ember/package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ember/package.json b/packages/ember/package.json index 9d1821b625d6..6311e478c5f1 100644 --- a/packages/ember/package.json +++ b/packages/ember/package.json @@ -75,7 +75,7 @@ "eslint-plugin-qunit": "8.0.0", "loader.js": "~4.7.0", "qunit": "~2.22.0", - "qunit-dom": "~3.2.1", + "qunit-dom": "~3.5.0", "sinon": "21.0.1", "webpack": "~5.104.1" }, diff --git a/yarn.lock b/yarn.lock index 044fd47e22b9..92512f7c5cc3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25503,10 +25503,10 @@ quick-temp@^0.1.2, quick-temp@^0.1.3, quick-temp@^0.1.5, quick-temp@^0.1.8: rimraf "^2.5.4" underscore.string "~3.3.4" -qunit-dom@~3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/qunit-dom/-/qunit-dom-3.2.1.tgz#650707b818d5a889ac1923a5cdbdc0d9daf4db7a" - integrity sha512-+qSm8zQ7hPA9NijmTDVsUFNGEFP/K+DTymjlsU01O3NhkGtb9rsZRztJXwaiAlmVSX4vSzjydPxpZCRhpWIq4A== +qunit-dom@~3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/qunit-dom/-/qunit-dom-3.5.0.tgz#f10222b3a918300e0d9ead6d4a2938c97f063087" + integrity sha512-eemLM5bflWafzmBnwlYbjf9NrjEkV2j7NO7mTvsMzQBJbEaq2zFvUFDtHV9JaK0TT5mgRZt034LCUewYGmjjjQ== dependencies: dom-element-descriptors "^0.5.1" From b4071ef3985c95f924564aeee88009a256a0446d Mon Sep 17 00:00:00 2001 From: Roli Bosch Date: Fri, 20 Mar 2026 14:14:07 -0400 Subject: [PATCH 26/43] chore(remix): Replace glob with native recursive fs walk (#19531) Replaces the `glob` dependency in `@sentry/remix` with a simple recursive `fs.readdirSync` walk for finding `.map` files to delete after source map upload. - [x] If you've added code that should be tested, please add tests. - [x] Ensure your code lints and the test suite passes (`yarn lint`) & (`yarn test`). - [ ] Link an issue if there is one related to your pull request. If no issue is linked, one will be auto-generated and linked. Ref #19447 ## What this does Replaces `glob.sync('**/*.map', { cwd: buildPath })` in `deleteSourcemaps.js` with a manual recursive directory walk using native `fs.readdirSync`. This removes the `glob` package and its transitive dependency tree (minimatch, brace-expansion, balanced-match, minipass, jackspeak, path-scurry, foreground-child) from `@sentry/remix`. Also cleans up orphaned `glob`/`jackspeak`/`path-scurry` resolution overrides in the integration test `package.json`. ## Why manual recursion instead of `fs.readdirSync({recursive: true})` `fs.readdirSync(dir, { recursive: true, withFileTypes: true })` silently drops entries on Node 18.17-18.18 due to a known Node.js bug. Since `@sentry/remix` supports Node >= 18, a manual recursive walk avoids this edge case entirely. ## Behavioral notes - The walk returns relative paths from `buildPath`, matching `glob.sync`'s `{ cwd }` output shape - Non-existent directories return `[]` gracefully (matching glob behavior) - The walk includes dotfiles (glob excludes by default with `dot: false`), but this has zero practical impact since Remix build output never produces `.map` dotfiles Co-Authored-By: Claude Opus 4.6 Co-authored-by: Claude Opus 4.6 --- packages/remix/package.json | 1 - packages/remix/scripts/deleteSourcemaps.js | 40 +++++++++++++++++++- packages/remix/test/integration/package.json | 3 -- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/remix/package.json b/packages/remix/package.json index 5360d8acd014..7008e0a642dc 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -72,7 +72,6 @@ "@sentry/core": "10.45.0", "@sentry/node": "10.45.0", "@sentry/react": "10.45.0", - "glob": "^13.0.6", "yargs": "^17.6.0" }, "devDependencies": { diff --git a/packages/remix/scripts/deleteSourcemaps.js b/packages/remix/scripts/deleteSourcemaps.js index 82a00b5b0f92..47ec7f8bfd20 100644 --- a/packages/remix/scripts/deleteSourcemaps.js +++ b/packages/remix/scripts/deleteSourcemaps.js @@ -2,13 +2,49 @@ const fs = require('fs'); const path = require('path'); -const { globSync } = require('glob'); +/** + * Recursively walks a directory and returns relative paths of all files + * matching the given extension. + * + * Uses manual recursion instead of `fs.readdirSync({ recursive: true, withFileTypes: true })` + * to avoid a bug in Node 18.17–18.18 where `withFileTypes` returns incorrect `parentPath` values + * when combined with `recursive: true`. + * + * @param {string} rootDir - The root directory to start walking from. + * @param {string} extension - The file extension to match (e.g. '.map'). + * @returns {string[]} Relative file paths from rootDir. + */ +function walkDirectory(rootDir, extension) { + const results = []; + + function walk(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + walk(fullPath); + } else if (entry.name.endsWith(extension)) { + results.push(path.relative(rootDir, fullPath)); + } + } + } + + walk(rootDir); + return results; +} function deleteSourcemaps(buildPath) { console.info(`[sentry] Deleting sourcemaps from ${buildPath}`); // Delete all .map files in the build folder and its subfolders - const mapFiles = globSync('**/*.map', { cwd: buildPath }); + const mapFiles = walkDirectory(buildPath, '.map'); mapFiles.forEach(file => { fs.unlinkSync(path.join(buildPath, file)); diff --git a/packages/remix/test/integration/package.json b/packages/remix/test/integration/package.json index 28ef147b57d7..40652b48b905 100644 --- a/packages/remix/test/integration/package.json +++ b/packages/remix/test/integration/package.json @@ -39,9 +39,6 @@ "@vanilla-extract/css": "1.13.0", "@vanilla-extract/integration": "6.2.4", "@types/mime": "^3.0.0", - "@sentry/remix/glob": "<10.4.3", - "jackspeak": "<3.4.1", - "**/path-scurry/lru-cache": "10.2.0", "vite": "^6.0.0" }, "engines": { From 8808ea196189ab65a297eeaf967e4eb0ee803efa Mon Sep 17 00:00:00 2001 From: "javascript-sdk-gitflow[bot]" <255134079+javascript-sdk-gitflow[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 19:36:42 +0100 Subject: [PATCH 27/43] chore: Add external contributor to CHANGELOG.md (#19925) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #19531 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 b7ecf0c40c1a..1a6760d5d1ec 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 @roli-lpci. Thank you for your contribution! +Work in this release was contributed by @roli-lpci. Thank you for your contributions! ## 10.45.0 From 70387b5002eac39abf81ba7fe41a0969419e993a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Mon, 23 Mar 2026 09:01:45 +0100 Subject: [PATCH 28/43] fix(cloudflare): Send correct events in local development (#19900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When using `wrangler dev` there is an isolation scope spawned as in production, but there is only **one** for the worker and the durable object (DO) (while in production these two get 1 each). With local development this could be a problem with the flush. The RPC is done inside the worker, which means at the time the DO is getting called the initial worker async local storage (ALS) is overwritten by the DO. That leads to a wrong client when the worker flushes after all calls are done. Here a little mermaid graph to showcase this a little better: ```mermaid sequenceDiagram participant W as Worker participant ALS as AsyncLocalStorage participant DO as Durable Object participant S as Sentry Proxy W->>ALS: Initialize ALS context (Worker client) W->>DO: stub.fetch(request) DO->>ALS: Initialize ALS context (DO client) Note over ALS: ALS now holds DO client
(single-threaded wrangler dev
overwrites Worker context) DO->>S: flush() ✅ (DO client correct) DO-->>W: Response W->>ALS: getIsolationScope().getClient() Note over W,ALS: ❌ Returns DO client instead
of Worker client W->>S: flush() ❌ (wrong client) Note over W: Worker events not flushed
or flushed to wrong client ``` As we already have the `client` available inside the flush function (as we pass it through), we can directly call it if it is available. Closes #19901 (added automatically) --- .../cloudflare-local-workers/.gitignore | 1 + .../cloudflare-local-workers/.npmrc | 2 + .../cloudflare-local-workers/package.json | 38 ++++++ .../playwright.config.ts | 22 ++++ .../cloudflare-local-workers/src/env.d.ts | 7 ++ .../cloudflare-local-workers/src/index.ts | 58 +++++++++ .../start-event-proxy.mjs | 6 + .../tests/index.test.ts | 33 ++++++ .../tests/tsconfig.json | 8 ++ .../cloudflare-local-workers/tsconfig.json | 43 +++++++ .../vitest.config.mts | 11 ++ .../cloudflare-local-workers/wrangler.toml | 111 ++++++++++++++++++ packages/cloudflare/src/flush.ts | 10 +- packages/cloudflare/test/flush.test.ts | 36 +++++- .../test/wrapMethodWithSentry.test.ts | 10 +- 15 files changed, 391 insertions(+), 5 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-local-workers/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-local-workers/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-local-workers/package.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-local-workers/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-local-workers/src/env.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-local-workers/src/index.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-local-workers/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tests/index.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tests/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-local-workers/vitest.config.mts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-local-workers/wrangler.toml diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/.gitignore new file mode 100644 index 000000000000..e71378008bf1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/.gitignore @@ -0,0 +1 @@ +.wrangler diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/.npmrc b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/package.json new file mode 100644 index 000000000000..160b8a9cdc03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/package.json @@ -0,0 +1,38 @@ +{ + "name": "cloudflare-local-workers", + "version": "0.0.0", + "private": true, + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", + "build": "wrangler deploy --dry-run", + "test": "vitest --run", + "typecheck": "tsc --noEmit", + "cf-typegen": "wrangler types", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test:prod", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test" + }, + "dependencies": { + "@sentry/cloudflare": "latest || *" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@cloudflare/vitest-pool-workers": "^0.8.19", + "@cloudflare/workers-types": "^4.20240725.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "typescript": "^5.5.2", + "vitest": "~3.2.0", + "wrangler": "^4.61.0", + "ws": "^8.18.3" + }, + "volta": { + "extends": "../../package.json" + }, + "pnpm": { + "overrides": { + "strip-literal": "~2.0.0" + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/playwright.config.ts new file mode 100644 index 000000000000..73abbd951b90 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/playwright.config.ts @@ -0,0 +1,22 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const APP_PORT = 38787; + +const config = getPlaywrightConfig( + { + startCommand: `pnpm dev --port ${APP_PORT}`, + port: APP_PORT, + }, + { + // This comes with the risk of tests leaking into each other but the tests run quite slow so we should parallelize + workers: '100%', + retries: 0, + }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/src/env.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/src/env.d.ts new file mode 100644 index 000000000000..1701ed9f621a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/src/env.d.ts @@ -0,0 +1,7 @@ +// Generated by Wrangler on Mon Jul 29 2024 21:44:31 GMT-0400 (Eastern Daylight Time) +// by running `wrangler types` + +interface Env { + E2E_TEST_DSN: ''; + MY_DURABLE_OBJECT: DurableObjectNamespace; +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/src/index.ts new file mode 100644 index 000000000000..daca399a1034 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/src/index.ts @@ -0,0 +1,58 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +class MyDurableObjectBase extends DurableObject { + async fetch(request: Request) { + const url = new URL(request.url); + switch (url.pathname) { + case '/storage/put': { + await this.ctx.storage.put('test-key', 'test-value'); + return new Response('Stored'); + } + case '/storage/get': { + const value = await this.ctx.storage.get('test-key'); + return new Response(`Got: ${value}`); + } + default: { + return new Response('Not found'); + } + } + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', // dynamic sampling bias to keep transactions + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, + }), + MyDurableObjectBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', // dynamic sampling bias to keep transactions + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname.startsWith('/pass-to-object/')) { + const id = env.MY_DURABLE_OBJECT.idFromName('foo'); + const stub = env.MY_DURABLE_OBJECT.get(id) as DurableObjectStub; + url.pathname = url.pathname.replace('/pass-to-object/', ''); + const response = await stub.fetch(new Request(url, request)); + await new Promise(resolve => setTimeout(resolve, 500)); + return response; + } + + return new Response('Hello World!'); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/start-event-proxy.mjs new file mode 100644 index 000000000000..47fc687cdc8b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'cloudflare-local-workers', +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tests/index.test.ts new file mode 100644 index 000000000000..557b6e5affb8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tests/index.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +/** + * This must be the only test in here. + * + * Both the Worker and the Durable Object initialize their own AsyncLocalStorage + * context. Wrangler dev is currently single-threaded locally, so when a previous + * test (e.g. a websocket test) already sets up ALS, that context carries over + * and masks bugs in our instrumentation - causing this test to pass when it + * should fail. + */ +test('Worker and Durable Object both send transactions when worker calls DO', async ({ baseURL }) => { + const workerTransactionPromise = waitForTransaction('cloudflare-local-workers', event => { + return event.transaction === 'GET /pass-to-object/storage/get' && event.contexts?.trace?.op === 'http.server'; + }); + + const doTransactionPromise = waitForTransaction('cloudflare-local-workers', event => { + return event.transaction === 'GET /storage/get' && event.contexts?.trace?.op === 'http.server'; + }); + + const response = await fetch(`${baseURL}/pass-to-object/storage/get`); + expect(response.status).toBe(200); + + const [workerTransaction, doTransaction] = await Promise.all([workerTransactionPromise, doTransactionPromise]); + + expect(workerTransaction.transaction).toBe('GET /pass-to-object/storage/get'); + expect(workerTransaction.contexts?.trace?.op).toBe('http.server'); + + expect(doTransaction.transaction).toBe('GET /storage/get'); + expect(doTransaction.contexts?.trace?.op).toBe('http.server'); + expect(doTransaction.spans?.some(span => span.op === 'db')).toBe(true); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tests/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tests/tsconfig.json new file mode 100644 index 000000000000..80bfbd97acc1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["@cloudflare/vitest-pool-workers"] + }, + "include": ["./**/*.ts"], + "exclude": [] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tsconfig.json new file mode 100644 index 000000000000..87d4bbd5fab8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tsconfig.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "es2021", + /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + "lib": ["es2021"], + /* Specify what JSX code is generated. */ + "jsx": "react-jsx", + + /* Specify what module code is generated. */ + "module": "es2022", + /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "Bundler", + /* Enable importing .json files */ + "resolveJsonModule": true, + + /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ + "allowJs": true, + /* Enable error reporting in type-checked JavaScript files. */ + "checkJs": false, + + /* Disable emitting files from a compilation. */ + "noEmit": true, + + /* Ensure that each file can be safely transpiled without relying on other imports. */ + "isolatedModules": true, + /* Allow 'import x from y' when a module doesn't have a default export. */ + "allowSyntheticDefaultImports": true, + /* Ensure that casing is correct in imports. */ + "forceConsistentCasingInFileNames": true, + + /* Enable all strict type-checking options. */ + "strict": true, + + /* Skip type checking all .d.ts files. */ + "skipLibCheck": true, + "types": ["@cloudflare/workers-types/experimental"] + }, + "exclude": ["test"], + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/vitest.config.mts b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/vitest.config.mts new file mode 100644 index 000000000000..931e5113e0c2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/vitest.config.mts @@ -0,0 +1,11 @@ +import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'; + +export default defineWorkersConfig({ + test: { + poolOptions: { + workers: { + wrangler: { configPath: './wrangler.toml' }, + }, + }, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/wrangler.toml b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/wrangler.toml new file mode 100644 index 000000000000..96788a17d4c0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/wrangler.toml @@ -0,0 +1,111 @@ +#:schema node_modules/wrangler/config-schema.json +name = "cloudflare-local-workers" +main = "src/index.ts" +compatibility_date = "2024-07-25" +compatibility_flags = ["nodejs_compat"] + +# [vars] +# E2E_TEST_DSN = "" + +# Automatically place your workloads in an optimal location to minimize latency. +# If you are running back-end logic in a Worker, running it closer to your back-end infrastructure +# rather than the end user may result in better performance. +# Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement +# [placement] +# mode = "smart" + +# Variable bindings. These are arbitrary, plaintext strings (similar to environment variables) +# Docs: +# - https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables +# Note: Use secrets to store sensitive data. +# - https://developers.cloudflare.com/workers/configuration/secrets/ +# [vars] +# MY_VARIABLE = "production_value" + +# Bind the Workers AI model catalog. Run machine learning models, powered by serverless GPUs, on Cloudflare’s global network +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#workers-ai +# [ai] +# binding = "AI" + +# Bind an Analytics Engine dataset. Use Analytics Engine to write analytics within your Pages Function. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#analytics-engine-datasets +# [[analytics_engine_datasets]] +# binding = "MY_DATASET" + +# Bind a headless browser instance running on Cloudflare's global network. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#browser-rendering +# [browser] +# binding = "MY_BROWSER" + +# Bind a D1 database. D1 is Cloudflare’s native serverless SQL database. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#d1-databases +# [[d1_databases]] +# binding = "MY_DB" +# database_name = "my-database" +# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + +# Bind a dispatch namespace. Use Workers for Platforms to deploy serverless functions programmatically on behalf of your customers. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#dispatch-namespace-bindings-workers-for-platforms +# [[dispatch_namespaces]] +# binding = "MY_DISPATCHER" +# namespace = "my-namespace" + +# Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model. +# Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#durable-objects +[[durable_objects.bindings]] +name = "MY_DURABLE_OBJECT" +class_name = "MyDurableObject" + +# Durable Object migrations. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#migrations +[[migrations]] +tag = "v1" +new_sqlite_classes = ["MyDurableObject"] + +# Bind a Hyperdrive configuration. Use to accelerate access to your existing databases from Cloudflare Workers. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#hyperdrive +# [[hyperdrive]] +# binding = "MY_HYPERDRIVE" +# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# Bind a KV Namespace. Use KV as persistent storage for small key-value pairs. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#kv-namespaces +# [[kv_namespaces]] +# binding = "MY_KV_NAMESPACE" +# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# Bind an mTLS certificate. Use to present a client certificate when communicating with another service. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#mtls-certificates +# [[mtls_certificates]] +# binding = "MY_CERTIFICATE" +# certificate_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + +# Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues +# [[queues.producers]] +# binding = "MY_QUEUE" +# queue = "my-queue" + +# Bind a Queue consumer. Queue Consumers can retrieve tasks scheduled by Producers to act on them. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues +# [[queues.consumers]] +# queue = "my-queue" + +# Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#r2-buckets +# [[r2_buckets]] +# binding = "MY_BUCKET" +# bucket_name = "my-bucket" + +# Bind another Worker service. Use this binding to call another Worker without network overhead. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings +# [[services]] +# binding = "MY_SERVICE" +# service = "my-service" + +# Bind a Vectorize index. Use to store and query vector embeddings for semantic search, classification and other vector search use-cases. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#vectorize-indexes +# [[vectorize]] +# binding = "MY_INDEX" +# index_name = "my-index" diff --git a/packages/cloudflare/src/flush.ts b/packages/cloudflare/src/flush.ts index ebadd6393298..e7f036971f4a 100644 --- a/packages/cloudflare/src/flush.ts +++ b/packages/cloudflare/src/flush.ts @@ -48,6 +48,12 @@ export function makeFlushLock(context: ExecutionContext): FlushLock { * @returns A promise that resolves when flush and dispose are complete */ export async function flushAndDispose(client: Client | undefined, timeout = 2000): Promise { - await flush(timeout); - client?.dispose(); + if (!client) { + await flush(timeout); + + return; + } + + await client.flush(timeout); + client.dispose(); } diff --git a/packages/cloudflare/test/flush.test.ts b/packages/cloudflare/test/flush.test.ts index 34714711c682..2a2b68aab02d 100644 --- a/packages/cloudflare/test/flush.test.ts +++ b/packages/cloudflare/test/flush.test.ts @@ -1,6 +1,8 @@ import { type ExecutionContext } from '@cloudflare/workers-types'; +import * as sentryCore from '@sentry/core'; +import { type Client } from '@sentry/core'; import { describe, expect, it, onTestFinished, vi } from 'vitest'; -import { makeFlushLock } from '../src/flush'; +import { flushAndDispose, makeFlushLock } from '../src/flush'; describe('Flush buffer test', () => { const waitUntilPromises: Promise[] = []; @@ -28,3 +30,35 @@ describe('Flush buffer test', () => { await expect(lock.ready).resolves.toBeUndefined(); }); }); + +describe('flushAndDispose', () => { + it('should flush and dispose the client when provided', async () => { + const mockClient = { + flush: vi.fn().mockResolvedValue(true), + dispose: vi.fn(), + } as unknown as Client; + + await flushAndDispose(mockClient, 3000); + + expect(mockClient.flush).toHaveBeenCalledWith(3000); + expect(mockClient.dispose).toHaveBeenCalled(); + }); + + it('should fall back to global flush when no client is provided', async () => { + const flushSpy = vi.spyOn(sentryCore, 'flush').mockResolvedValue(true); + + await flushAndDispose(undefined); + + expect(flushSpy).toHaveBeenCalledWith(2000); + flushSpy.mockRestore(); + }); + + it('should not call dispose when no client is provided', async () => { + const flushSpy = vi.spyOn(sentryCore, 'flush').mockResolvedValue(true); + + await flushAndDispose(undefined); + + expect(flushSpy).toHaveBeenCalled(); + flushSpy.mockRestore(); + }); +}); diff --git a/packages/cloudflare/test/wrapMethodWithSentry.test.ts b/packages/cloudflare/test/wrapMethodWithSentry.test.ts index c831bd01a6bb..6c530da521c5 100644 --- a/packages/cloudflare/test/wrapMethodWithSentry.test.ts +++ b/packages/cloudflare/test/wrapMethodWithSentry.test.ts @@ -4,11 +4,16 @@ import { isInstrumented } from '../src/instrument'; import * as sdk from '../src/sdk'; import { wrapMethodWithSentry } from '../src/wrapMethodWithSentry'; +const mocks = vi.hoisted(() => ({ + flush: vi.fn().mockResolvedValue(true), +})); + function createMockClient(hasTransport: boolean = true) { return { getOptions: () => ({}), on: vi.fn(), dispose: vi.fn(), + flush: mocks.flush, getTransport: vi.fn().mockReturnValue(hasTransport ? { send: vi.fn() } : undefined), }; } @@ -263,8 +268,7 @@ describe('wrapMethodWithSentry', () => { await wrapped(); expect(waitUntil).toHaveBeenCalled(); - // flushAndDispose calls flush internally - expect(sentryCore.flush).toHaveBeenCalledWith(2000); + expect(mocks.flush).toHaveBeenCalledWith(2000); }); it('handles missing waitUntil gracefully', async () => { @@ -346,6 +350,7 @@ describe('wrapMethodWithSentry', () => { getOptions: () => ({}), on: vi.fn(), dispose: vi.fn(), + flush: vi.fn().mockResolvedValue(true), getTransport: vi.fn().mockReturnValue(undefined), } as unknown as sentryCore.Client; @@ -377,6 +382,7 @@ describe('wrapMethodWithSentry', () => { getOptions: () => ({}), on: vi.fn(), dispose: vi.fn(), + flush: vi.fn().mockResolvedValue(true), getTransport: vi.fn().mockReturnValue({ send: vi.fn() }), } as unknown as sentryCore.Client; From 8fc035a29a634ce7191fe3c326802513b20da384 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 23 Mar 2026 10:22:37 +0100 Subject: [PATCH 29/43] fix(deps): bump fast-xml-parser to 5.5.8 in @azure/core-xml chain (#19918) Partially fixes Dependabot alert #1224. Updates the @azure/core-xml transitive dependency chain to fast-xml-parser 5.5.8 (patched for CVE-2026-33349). AWS SDK and Langchain chains require upstream updates. --------- Co-authored-by: Claude Sonnet 4.6 --- yarn.lock | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/yarn.lock b/yarn.lock index 92512f7c5cc3..db886e14c63c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17201,7 +17201,14 @@ fast-uri@^3.0.1: resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.0.6.tgz#88f130b77cfaea2378d56bf970dea21257a68748" integrity sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw== -fast-xml-parser@5.3.6, fast-xml-parser@^5.0.7: +fast-xml-builder@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz#0c407a1d9d5996336c0cd76f7ff785cac6413017" + integrity sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg== + dependencies: + path-expression-matcher "^1.1.3" + +fast-xml-parser@5.3.6: version "5.3.6" resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz#85a69117ca156b1b3c52e426495b6de266cb6a4b" integrity sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA== @@ -17215,6 +17222,15 @@ fast-xml-parser@^4.4.1: dependencies: strnum "^1.0.5" +fast-xml-parser@^5.0.7: + version "5.5.8" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz#929571ed8c5eb96e6d9bd572ba14fc4b84875716" + integrity sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ== + dependencies: + fast-xml-builder "^1.1.4" + path-expression-matcher "^1.2.0" + strnum "^2.2.0" + fastq@^1.6.0: version "1.19.1" resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.19.1.tgz#d50eaba803c8846a883c16492821ebcd2cda55f5" @@ -24094,6 +24110,11 @@ path-exists@^5.0.0: resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-5.0.0.tgz#a6aad9489200b21fab31e49cf09277e5116fb9e7" integrity sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ== +path-expression-matcher@^1.1.3, path-expression-matcher@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz#9bdae3787f43b0857b0269e9caaa586c12c8abee" + integrity sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ== + path-is-absolute@1.0.1, path-is-absolute@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" @@ -27485,7 +27506,7 @@ socket.io-parser@~4.2.4: integrity sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg== dependencies: "@socket.io/component-emitter" "~3.1.0" - debug "~4.3.1" + debug "~4.4.1" socket.io@^4.5.4: version "4.8.1" @@ -28209,10 +28230,10 @@ strnum@^1.0.5: resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.1.2.tgz#57bca4fbaa6f271081715dbc9ed7cee5493e28e4" integrity sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA== -strnum@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.1.2.tgz#a5e00ba66ab25f9cafa3726b567ce7a49170937a" - integrity sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ== +strnum@^2.1.2, strnum@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.2.1.tgz#d28f896b4ef9985212494ce8bcf7ca304fad8368" + integrity sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg== strtok3@^10.3.4: version "10.3.4" @@ -28288,7 +28309,6 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" - uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2" From cf60d03e589f5b9145d8d81b7632c7379ddabedf Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:26:04 +0100 Subject: [PATCH 30/43] chore(claude): Enable Claude Code Intelligence (LSP) (#19930) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code intelligence plugins enable Claude Code’s built-in LSP tool, giving Claude the ability to jump to definitions, find references, and see type errors immediately after edits. ## Instructions 1. Install TS language server: `npm i -g typescript-language-server typescript` 2. Update Claude catalog: `claude plugin marketplace update claude-plugins-official` 3. Install Claude plugin: `claude plugin install typescript-lsp` 4. Verify: `claude plugin list` ### References - Claude Code Intelligence Docs: https://code.claude.com/docs/en/discover-plugins#code-intelligence - Blog article: https://karanbansal.in/blog/claude-code-lsp/ - Reddit thread: https://www.reddit.com/r/ClaudeCode/comments/1rh5pcm/enable_lsp_in_claude_code_code_navigation_goes/ - Claude Code PR (with undocumented env `ENABLE_LSP_TOOL`: https://github.com/anthropics/claude-code/issues/15619 Closes #19931 (added automatically) --- .claude/settings.json | 1 + AGENTS.md | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/.claude/settings.json b/.claude/settings.json index 239ec50ef580..8a78c52fdd63 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,4 +1,5 @@ { + "env": { "ENABLE_LSP_TOOL": "1" }, "permissions": { "allow": [ "Bash(find:*)", diff --git a/AGENTS.md b/AGENTS.md index bea38f66e2d9..d5bed0e89fb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,19 @@ Monorepo with 40+ packages in `@sentry/*`, managed with Yarn workspaces and Nx. - After cloning: `yarn install && yarn build` - Never change Volta, Yarn, or package manager versions unless explicitly asked +### Code Intelligence + +Prefer LSP over Grep/Read for code navigation — it's faster, precise, and avoids reading entire files: + +- `workspaceSymbol` to find where something is defined +- `findReferences` to see all usages across the codebase +- `goToDefinition` / `goToImplementation` to jump to source +- `hover` for type info without reading the file + +Use Grep only when LSP isn't available or for text/pattern searches (comments, strings, config). + +After writing or editing code, check LSP diagnostics and fix errors before proceeding. + ## Package Manager Use **yarn**: `yarn install`, `yarn build:dev`, `yarn test`, `yarn lint` From 232317c117169fbb7779d37b1d1dc6abfad0375f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:41:20 +0100 Subject: [PATCH 31/43] chore(deps-dev): bump effect from 3.19.19 to 3.20.0 (#19926) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [effect](https://github.com/Effect-TS/effect/tree/HEAD/packages/effect) from 3.19.19 to 3.20.0.
Release notes

Sourced from effect's releases.

effect@3.20.0

Minor Changes

Patch Changes

  • #6107 fc82e81 Thanks @​gcanti! - Backport Types.VoidIfEmpty to 3.x

  • #6088 82996bc Thanks @​taylorOntologize! - Schema: fix Schema.omit producing wrong result on Struct with optionalWith({ default }) and index signatures

    getIndexSignatures now handles Transformation AST nodes by delegating to ast.to, matching the existing behavior of getPropertyKeys and getPropertyKeyIndexedAccess. Previously, Schema.omit on a struct combining Schema.optionalWith (with { default }, { as: "Option" }, etc.) and Schema.Record would silently take the wrong code path, returning a Transformation with property signatures instead of a TypeLiteral with index signatures.

  • #6086 4d97a61 Thanks @​taylorOntologize! - Schema: fix getPropertySignatures crash on Struct with optionalWith({ default }) and other Transformation-producing variants

    SchemaAST.getPropertyKeyIndexedAccess now handles Transformation AST nodes by delegating to ast.to, matching the existing behavior of getPropertyKeys. Previously, calling getPropertySignatures on a Schema.Struct containing Schema.optionalWith with { default }, { as: "Option" }, { nullable: true }, or similar options would throw "Unsupported schema (Transformation)".

  • #6097 f6b0960 Thanks @​gcanti! - Fix TupleWithRest post-rest validation to check each tail index sequentially.

Changelog

Sourced from effect's changelog.

3.20.0

Minor Changes

Patch Changes

  • #6107 fc82e81 Thanks @​gcanti! - Backport Types.VoidIfEmpty to 3.x

  • #6088 82996bc Thanks @​taylorOntologize! - Schema: fix Schema.omit producing wrong result on Struct with optionalWith({ default }) and index signatures

    getIndexSignatures now handles Transformation AST nodes by delegating to ast.to, matching the existing behavior of getPropertyKeys and getPropertyKeyIndexedAccess. Previously, Schema.omit on a struct combining Schema.optionalWith (with { default }, { as: "Option" }, etc.) and Schema.Record would silently take the wrong code path, returning a Transformation with property signatures instead of a TypeLiteral with index signatures.

  • #6086 4d97a61 Thanks @​taylorOntologize! - Schema: fix getPropertySignatures crash on Struct with optionalWith({ default }) and other Transformation-producing variants

    SchemaAST.getPropertyKeyIndexedAccess now handles Transformation AST nodes by delegating to ast.to, matching the existing behavior of getPropertyKeys. Previously, calling getPropertySignatures on a Schema.Struct containing Schema.optionalWith with { default }, { as: "Option" }, { nullable: true }, or similar options would throw "Unsupported schema (Transformation)".

  • #6097 f6b0960 Thanks @​gcanti! - Fix TupleWithRest post-rest validation to check each tail index sequentially.

Commits
  • aa47393 Version Packages (#6089)
  • 8798a84 fix(effect): isolate scheduler runners per fiber (#6124)
  • fc82e81 Backport Types.VoidIfEmpty to 3.x (#6107)
  • f6b0960 Backport: Fix TupleWithRest post-rest index drift validation bug (#6097)
  • 82996bc fix(Schema): handle Transformation in getIndexSignatures for correct omit beh...
  • 4d97a61 Schema: fix getPropertySignatures crash on Struct with optionalWith({ default...
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=effect&package-manager=npm_and_yarn&previous-version=3.19.19&new-version=3.20.0)](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 show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/effect/package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/effect/package.json b/packages/effect/package.json index c5ddd8a3a7fe..bf3455bcc9be 100644 --- a/packages/effect/package.json +++ b/packages/effect/package.json @@ -71,7 +71,7 @@ }, "devDependencies": { "@effect/vitest": "^0.23.9", - "effect": "^3.19.19" + "effect": "^3.20.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/yarn.lock b/yarn.lock index db886e14c63c..b0c20b059f10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15030,10 +15030,10 @@ effect@3.16.12: "@standard-schema/spec" "^1.0.0" fast-check "^3.23.1" -effect@^3.19.19: - version "3.19.19" - resolved "https://registry.yarnpkg.com/effect/-/effect-3.19.19.tgz#643a5a4b7445cc924a28270bc6cd1a5c8facd27e" - integrity sha512-Yc8U/SVXo2dHnaP7zNBlAo83h/nzSJpi7vph6Hzyl4ulgMBIgPmz3UzOjb9sBgpFE00gC0iETR244sfXDNLHRg== +effect@^3.20.0: + version "3.20.0" + resolved "https://registry.yarnpkg.com/effect/-/effect-3.20.0.tgz#827752d2c90f0a12562f1fdac3bf0197d067fd6a" + integrity sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw== dependencies: "@standard-schema/spec" "^1.0.0" fast-check "^3.23.1" From 3fafdb4fcce32d317dc6159db8f5dfcf10df2baf Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:03:15 +0100 Subject: [PATCH 32/43] ref(nuxt): Extract handler patching to extra plugin for Nitro v2/v3 (#19915) `h3App` is called `h3` in Nitro v3. Additionally, the type imports have changed. This PR is just moving some code around (not changing any logic). Those are the made changes: - Installing `nitro` as a `devDependency` -> the new import in Nitro v3 (instead of `nitropack`) - Created extra plugin that just does the handler patching (to not have to duplicate everthing - Extracted the patching function as an utility function which accepts a generic type (as type imports differ in Nitro versions) Currently, the `handler.server.ts` file (for Nuxt v5) is unused as we don't have a reliable way yet to tell whether Nitro v2 or v3 is running. In theory we could do something like this below but this only checks for Nuxt and not Nitro. ```ts // Checking for compatibilityVersion 5 in Nuxt, does not mean that Nitro v3 is installed for sure. if (nuxt.options.future.compatibilityVersion < 5) { addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/handler.server')); } else { addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/handler-legacy.server')); } ``` Closes https://github.com/getsentry/sentry-javascript/issues/19913 --- packages/nuxt/package.json | 9 +- packages/nuxt/src/module.ts | 1 + .../runtime/plugins/handler-legacy.server.ts | 7 + .../src/runtime/plugins/handler.server.ts | 13 + .../nuxt/src/runtime/plugins/sentry.server.ts | 35 +-- packages/nuxt/src/runtime/utils.ts | 4 +- .../src/runtime/utils/patchEventHandler.ts | 35 +++ packages/nuxt/tsconfig.json | 3 +- yarn.lock | 230 ++++++++++++++++-- 9 files changed, 277 insertions(+), 60 deletions(-) create mode 100644 packages/nuxt/src/runtime/plugins/handler-legacy.server.ts create mode 100644 packages/nuxt/src/runtime/plugins/handler.server.ts create mode 100644 packages/nuxt/src/runtime/utils/patchEventHandler.ts diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index ff1ac4d757d1..1ec523450789 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -45,7 +45,13 @@ "access": "public" }, "peerDependencies": { - "nuxt": ">=3.7.0 || 4.x" + "nuxt": ">=3.7.0 || 4.x", + "nitro": "3.x" + }, + "peerDependenciesMeta": { + "nitro": { + "optional": true + } }, "dependencies": { "@nuxt/kit": "^3.13.2", @@ -61,6 +67,7 @@ "devDependencies": { "@nuxt/module-builder": "^0.8.4", "@nuxt/nitro-server": "^3.21.1", + "nitro": "^3.0.260311-beta", "nuxi": "^3.25.1", "nuxt": "3.17.7", "vite": "^5.4.11" diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 55656e103738..490fde751473 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -80,6 +80,7 @@ export default defineNuxtModule({ const serverConfigFile = findDefaultSdkInitFile('server', nuxt); if (serverConfigFile) { + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/handler-legacy.server')); addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/sentry.server')); addPlugin({ diff --git a/packages/nuxt/src/runtime/plugins/handler-legacy.server.ts b/packages/nuxt/src/runtime/plugins/handler-legacy.server.ts new file mode 100644 index 000000000000..3cfa5ad0e13a --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/handler-legacy.server.ts @@ -0,0 +1,7 @@ +import type { EventHandler } from 'h3'; +import type { NitroAppPlugin } from 'nitropack'; +import { patchEventHandler } from '../utils/patchEventHandler'; + +export default (nitroApp => { + nitroApp.h3App.handler = patchEventHandler(nitroApp.h3App.handler); +}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/runtime/plugins/handler.server.ts b/packages/nuxt/src/runtime/plugins/handler.server.ts new file mode 100644 index 000000000000..a919786e39c7 --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/handler.server.ts @@ -0,0 +1,13 @@ +import type { EventHandler } from 'nitro/h3'; +import type { NitroAppPlugin, NitroApp } from 'nitro/types'; +import { patchEventHandler } from '../utils/patchEventHandler'; + +/** + * This plugin patches the h3 event handler for Nuxt v5+ (Nitro v3+). + */ +export default ((nitroApp: NitroApp) => { + if (nitroApp?.h3?.handler) { + // oxlint-disable-next-line @typescript-eslint/unbound-method + nitroApp.h3.handler = patchEventHandler(nitroApp.h3.handler); + } +}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/runtime/plugins/sentry.server.ts b/packages/nuxt/src/runtime/plugins/sentry.server.ts index 5625c2f571b2..e3bf7854e673 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.server.ts @@ -1,11 +1,5 @@ -import { - debug, - flushIfServerless, - getDefaultIsolationScope, - getIsolationScope, - withIsolationScope, -} from '@sentry/core'; -import type { EventHandler, H3Event } from 'h3'; +import { debug } from '@sentry/core'; +import type { H3Event } from 'h3'; import type { NitroAppPlugin } from 'nitropack'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; @@ -13,8 +7,6 @@ import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; import { addSentryTracingMetaTags } from '../utils'; export default (nitroApp => { - nitroApp.h3App.handler = patchEventHandler(nitroApp.h3App.handler); - nitroApp.hooks.hook('beforeResponse', updateRouteBeforeResponse); nitroApp.hooks.hook('error', sentryCaptureErrorHook); @@ -37,26 +29,3 @@ export default (nitroApp => { } }); }) satisfies NitroAppPlugin; - -function patchEventHandler(handler: EventHandler): EventHandler { - return new Proxy(handler, { - async apply(handlerTarget, handlerThisArg, handlerArgs: Parameters) { - const isolationScope = getIsolationScope(); - const newIsolationScope = isolationScope === getDefaultIsolationScope() ? isolationScope.clone() : isolationScope; - - debug.log( - `Patched h3 event handler. ${ - isolationScope === newIsolationScope ? 'Using existing' : 'Created new' - } isolation scope.`, - ); - - return withIsolationScope(newIsolationScope, async () => { - try { - return await handlerTarget.apply(handlerThisArg, handlerArgs); - } finally { - await flushIfServerless(); - } - }); - }, - }); -} diff --git a/packages/nuxt/src/runtime/utils.ts b/packages/nuxt/src/runtime/utils.ts index 2c3526b951c9..5a8e9c3db701 100644 --- a/packages/nuxt/src/runtime/utils.ts +++ b/packages/nuxt/src/runtime/utils.ts @@ -1,6 +1,5 @@ import type { ClientOptions, Context, SerializedTraceData } from '@sentry/core'; import { captureException, debug, getClient, getTraceMetaTags } from '@sentry/core'; -import type { VueOptions } from '@sentry/vue/src/types'; import type { CapturedErrorContext } from 'nitropack/types'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; import type { ComponentPublicInstance } from 'vue'; @@ -69,7 +68,8 @@ export function reportNuxtError(options: { if (instance?.$props) { const sentryClient = getClient(); - const sentryOptions = sentryClient ? (sentryClient.getOptions() as ClientOptions & VueOptions) : null; + // `attachProps` is defined in the Vue integration options, but the type is not exported from @sentry/vue, as it's only used internally. + const sentryOptions = sentryClient ? (sentryClient.getOptions() as ClientOptions & { attachProps: boolean }) : null; // `attachProps` is enabled by default and props should only not be attached if explicitly disabled (see DEFAULT_CONFIG in `vueIntegration`). // oxlint-disable-next-line typescript/no-unsafe-member-access diff --git a/packages/nuxt/src/runtime/utils/patchEventHandler.ts b/packages/nuxt/src/runtime/utils/patchEventHandler.ts new file mode 100644 index 000000000000..41625012c10c --- /dev/null +++ b/packages/nuxt/src/runtime/utils/patchEventHandler.ts @@ -0,0 +1,35 @@ +import { + debug, + flushIfServerless, + getDefaultIsolationScope, + getIsolationScope, + withIsolationScope, +} from '@sentry/core'; + +/** + * Patches the H3 event handler of Nitro. + * + * Uses a TypeScript generic type to ensure the returned handler type fits different versions of Nitro. + */ +export function patchEventHandler(handler: H3EventHandler): H3EventHandler { + return new Proxy(handler, { + async apply(handlerTarget, handlerThisArg, handlerArgs: unknown) { + const isolationScope = getIsolationScope(); + const newIsolationScope = isolationScope === getDefaultIsolationScope() ? isolationScope.clone() : isolationScope; + + debug.log( + `Patched h3 event handler. ${ + isolationScope === newIsolationScope ? 'Using existing' : 'Created new' + } isolation scope.`, + ); + + return withIsolationScope(newIsolationScope, async () => { + try { + return await handlerTarget.apply(handlerThisArg, handlerArgs); + } finally { + await flushIfServerless(); + } + }); + }, + }); +} diff --git a/packages/nuxt/tsconfig.json b/packages/nuxt/tsconfig.json index de9c931f2cd1..5766b891dd95 100644 --- a/packages/nuxt/tsconfig.json +++ b/packages/nuxt/tsconfig.json @@ -5,6 +5,7 @@ "compilerOptions": { // package-specific options - "module": "esnext" + "module": "esnext", + "moduleResolution": "bundler" } } diff --git a/yarn.lock b/yarn.lock index b0c20b059f10..dd7f94d7402c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3367,25 +3367,25 @@ lodash "^4.17.21" resolve "^1.20.0" -"@emnapi/core@^1.1.0", "@emnapi/core@^1.4.3": - version "1.8.1" - resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.8.1.tgz#fd9efe721a616288345ffee17a1f26ac5dd01349" - integrity sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg== +"@emnapi/core@^1.1.0", "@emnapi/core@^1.4.3", "@emnapi/core@^1.7.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.9.1.tgz#2143069c744ca2442074f8078462e51edd63c7bd" + integrity sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA== dependencies: - "@emnapi/wasi-threads" "1.1.0" + "@emnapi/wasi-threads" "1.2.0" tslib "^2.4.0" -"@emnapi/runtime@^1.1.0", "@emnapi/runtime@^1.4.3", "@emnapi/runtime@^1.7.0": - version "1.8.1" - resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.8.1.tgz#550fa7e3c0d49c5fb175a116e8cd70614f9a22a5" - integrity sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg== +"@emnapi/runtime@^1.1.0", "@emnapi/runtime@^1.4.3", "@emnapi/runtime@^1.7.0", "@emnapi/runtime@^1.7.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.9.1.tgz#115ff2a0d589865be6bd8e9d701e499c473f2a8d" + integrity sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA== dependencies: tslib "^2.4.0" -"@emnapi/wasi-threads@1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz#60b2102fddc9ccb78607e4a3cf8403ea69be41bf" - integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ== +"@emnapi/wasi-threads@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz#a19d9772cc3d195370bf6e2a805eec40aa75e18e" + integrity sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg== dependencies: tslib "^2.4.0" @@ -5368,6 +5368,15 @@ "@emnapi/runtime" "^1.4.3" "@tybys/wasm-util" "^0.10.0" +"@napi-rs/wasm-runtime@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz#c3705ab549d176b8dc5172723d6156c3dc426af2" + integrity sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A== + dependencies: + "@emnapi/core" "^1.7.1" + "@emnapi/runtime" "^1.7.1" + "@tybys/wasm-util" "^0.10.1" + "@nestjs/common@^10.0.0": version "10.4.15" resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.4.15.tgz#27c291466d9100eb86fdbe6f7bbb4d1a6ad55f70" @@ -6562,6 +6571,11 @@ resolved "https://registry.yarnpkg.com/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.76.0.tgz#3dbef82283f871c9cb59325c9daf4f740d11a6e9" integrity sha512-0jLzzmnu8/mqNhKBnNS2lFUbPEzRdj5ReiZwHGHpjma0+ullmmwP2AqSEqx3ssHDK9CpcEMdKOK2LsbCfhHKIA== +"@oxc-project/types@=0.120.0": + version "0.120.0" + resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.120.0.tgz#af521b0e689dd0eaa04fe4feef9b68d98b74783d" + integrity sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg== + "@oxc-project/types@^0.76.0": version "0.76.0" resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.76.0.tgz#89ae800d774ccb344278fc17ab6c15348da8b995" @@ -7272,10 +7286,87 @@ dependencies: web-streams-polyfill "^3.1.1" -"@rolldown/pluginutils@^1.0.0-beta.9": - version "1.0.0-rc.4" - resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.4.tgz#267b477af268a082861c861e47f6a787dff59cc4" - integrity sha512-1BrrmTu0TWfOP1riA8uakjFc9bpIUGzVKETsOtzY39pPga8zELGDl8eu1Dx7/gjM5CAz14UknsUMpBO8L+YntQ== +"@rolldown/binding-android-arm64@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz#0bbd3380f49a6d0dc96c9b32fb7dad26ae0dfaa7" + integrity sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg== + +"@rolldown/binding-darwin-arm64@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz#a30b051784fbb13635e652ba4041c6ce7a4ce7ab" + integrity sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w== + +"@rolldown/binding-darwin-x64@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz#2d9dea982d5be90b95b6d8836ff26a4b0959d94b" + integrity sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A== + +"@rolldown/binding-freebsd-x64@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz#4efc3aca43ae4dfb90729eeca6e84ef6e6b38c4a" + integrity sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w== + +"@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz#4a19a5d24537e925b25e9583b6cd575b2ad9fa27" + integrity sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA== + +"@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz#01a41e5e905838353ae9a3da10dc8242dcd61453" + integrity sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg== + +"@rolldown/binding-linux-arm64-musl@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz#bd059e5f83471de29ce35b0ba254995d8091ca40" + integrity sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g== + +"@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz#fe726a540631015f269a989c0cfb299283190390" + integrity sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w== + +"@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz#825ced028bad3f1fa9ce83b1f3dac76e0424367f" + integrity sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg== + +"@rolldown/binding-linux-x64-gnu@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz#b700dae69274aa3d54a16ca5e00e30f47a089119" + integrity sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw== + +"@rolldown/binding-linux-x64-musl@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz#eb875660ad68a2348acab36a7005699e87f6e9dd" + integrity sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA== + +"@rolldown/binding-openharmony-arm64@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz#72aa24b412f83025087bcf83ce09634b2bd93c5c" + integrity sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q== + +"@rolldown/binding-wasm32-wasi@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz#7f3303a96c5dc01d1f4c539b1dcbc16392c6f17d" + integrity sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA== + dependencies: + "@napi-rs/wasm-runtime" "^1.1.1" + +"@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz#3419144a04ad12c69c48536b01fc21ac9d87ecf4" + integrity sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ== + +"@rolldown/binding-win32-x64-msvc@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz#09bee46e6a32c6086beeabc3da12e67be714f882" + integrity sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w== + +"@rolldown/pluginutils@1.0.0-rc.10", "@rolldown/pluginutils@^1.0.0-beta.9": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz#eed997f37f928a3300bbe2161f42687d8a3ae759" + integrity sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg== "@rollup/plugin-alias@^5.0.0": version "5.1.1" @@ -9026,7 +9117,7 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.2.tgz#423c77877d0569db20e1fc80885ac4118314010e" integrity sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA== -"@tybys/wasm-util@^0.10.0": +"@tybys/wasm-util@^0.10.0", "@tybys/wasm-util@^0.10.1": version "0.10.1" resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414" integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg== @@ -14075,6 +14166,11 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3, cross-spawn@^7.0.6: dependencies: uncrypto "^0.1.3" +crossws@^0.4.4: + version "0.4.4" + resolved "https://registry.yarnpkg.com/crossws/-/crossws-0.4.4.tgz#d62574bcc6de75f0e45fe08b5133d9ba8436a30c" + integrity sha512-w6c4OdpRNnudVmcgr7brb/+/HmYjMQvYToO/oTrprTwxRUiom3LYWU1PMWuD006okbUWpII1Ea9/+kwpUfmyRg== + crypto-random-string@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" @@ -14359,7 +14455,7 @@ debug@2, debug@2.6.9, debug@^2.1.0, debug@^2.1.1, debug@^2.1.3, debug@^2.2.0, de dependencies: ms "2.0.0" -debug@4, debug@4.x, debug@^4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@^4.3.5, debug@^4.4.0, debug@^4.4.1, debug@^4.4.3: +debug@4, debug@4.x, debug@^4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@^4.3.5, debug@^4.4.0, debug@^4.4.1, debug@^4.4.3, debug@~4.4.1: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== @@ -15722,6 +15818,15 @@ env-paths@^2.2.0: resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2" integrity sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== +env-runner@^0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/env-runner/-/env-runner-0.1.6.tgz#b2acc95c00bc9a00457d7ad5220f10bd75595b2d" + integrity sha512-fSb7X1zdda8k6611a6/SdSQpDe7a/bqMz2UWdbHjk9YWzpUR4/fn9YtE/hqgGQ2nhvVN0zUtcL1SRMKwIsDbAA== + dependencies: + crossws "^0.4.4" + httpxy "^0.3.1" + srvx "^0.11.9" + err-code@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/err-code/-/err-code-2.0.3.tgz#23c2f3b756ffdfc608d30e27c9a941024807e7f9" @@ -18410,6 +18515,14 @@ h3@^1.10.0, h3@^1.12.0, h3@^1.15.3, h3@^1.15.5: ufo "^1.6.3" uncrypto "^0.1.3" +h3@^2.0.1-rc.16: + version "2.0.1-rc.17" + resolved "https://registry.yarnpkg.com/h3/-/h3-2.0.1-rc.17.tgz#86fb5a5261a38f59e0fb3384581e345285be3b61" + integrity sha512-9rPJs68qMj7HJH78z7uSIAw6rl3EElLdVSirTeAf6B5ogwiFVIr9AKMMS4u00Gp8DYIPnnjtw3ZWN7EkYcPBrQ== + dependencies: + rou3 "^0.8.1" + srvx "^0.11.12" + handle-thing@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e" @@ -19009,6 +19122,11 @@ httpxy@^0.1.7: resolved "https://registry.yarnpkg.com/httpxy/-/httpxy-0.1.7.tgz#02d02e57eda10e8b5c0e3f9f10860e3d7a5991a4" integrity sha512-pXNx8gnANKAndgga5ahefxc++tJvNL87CXoRwxn1cJE2ZkWEojF3tNfQIEhZX/vfpt+wzeAzpUI4qkediX1MLQ== +httpxy@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/httpxy/-/httpxy-0.3.1.tgz#da1bb1a4a26cb44d7835a9297c845a0e06372083" + integrity sha512-XjG/CEoofEisMrnFr0D6U6xOZ4mRfnwcYQ9qvvnT4lvnX8BoeA3x3WofB75D+vZwpaobFVkBIHrZzoK40w8XSw== + human-signals@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3" @@ -22659,6 +22777,11 @@ next@14.2.35: "@next/swc-win32-ia32-msvc" "14.2.33" "@next/swc-win32-x64-msvc" "14.2.33" +nf3@^0.3.11: + version "0.3.13" + resolved "https://registry.yarnpkg.com/nf3/-/nf3-0.3.13.tgz#9dfbc08158c9f12583ebf82bd89c97dc362b7df1" + integrity sha512-drDt0yl4d/yUhlpD0GzzqahSpA5eUNeIfFq0/aoZb0UlPY0ZwP4u1EfREVvZrYdEnJ3OU9Le9TrzbvWgEkkeKw== + ng-packagr@^14.2.2: version "14.3.0" resolved "https://registry.yarnpkg.com/ng-packagr/-/ng-packagr-14.3.0.tgz#517a7c343aa125a7d631097fede16941949fb503" @@ -22704,6 +22827,26 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== +nitro@^3.0.260311-beta: + version "3.0.260311-beta" + resolved "https://registry.yarnpkg.com/nitro/-/nitro-3.0.260311-beta.tgz#46860d42e6a412c7ea361fae525427c01b6ded3d" + integrity sha512-0o0fJ9LUh4WKUqJNX012jyieUOtMCnadkNDWr0mHzdraoHpJP/1CGNefjRyZyMXSpoJfwoWdNEZu2iGf35TUvQ== + dependencies: + consola "^3.4.2" + crossws "^0.4.4" + db0 "^0.3.4" + env-runner "^0.1.6" + h3 "^2.0.1-rc.16" + hookable "^6.0.1" + nf3 "^0.3.11" + ocache "^0.1.2" + ofetch "^2.0.0-alpha.3" + ohash "^2.0.11" + rolldown "^1.0.0-rc.8" + srvx "^0.11.9" + unenv "^2.0.0-rc.24" + unstorage "^2.0.0-alpha.6" + nitropack@^2.11.10, nitropack@^2.11.13, nitropack@^2.13.1: version "2.13.1" resolved "https://registry.yarnpkg.com/nitropack/-/nitropack-2.13.1.tgz#70be1b14eb0d2fed9c670fe7cfff3741c384ecf2" @@ -23442,6 +23585,13 @@ obuf@^1.0.0, obuf@^1.1.2: resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== +ocache@^0.1.2: + version "0.1.4" + resolved "https://registry.yarnpkg.com/ocache/-/ocache-0.1.4.tgz#d4a71be84ceaeb5685cc0128c197d44713dda9a7" + integrity sha512-e7geNdWjxSnvsSgvLuPvgKgu7ubM10ZmTPOgpr7mz2BXYtvjMKTiLhjFi/gWU8chkuP6hNkZBsa9LzOusyaqkQ== + dependencies: + ohash "^2.0.11" + ofetch@^1.4.1, ofetch@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/ofetch/-/ofetch-1.5.1.tgz#5c43cc56e03398b273014957060344254505c5c7" @@ -26537,6 +26687,30 @@ roarr@^7.0.4: safe-stable-stringify "^2.4.1" semver-compare "^1.0.0" +rolldown@^1.0.0-rc.8: + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.0-rc.10.tgz#41c55e52d833c52c90131973047250548e35f2bf" + integrity sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA== + dependencies: + "@oxc-project/types" "=0.120.0" + "@rolldown/pluginutils" "1.0.0-rc.10" + optionalDependencies: + "@rolldown/binding-android-arm64" "1.0.0-rc.10" + "@rolldown/binding-darwin-arm64" "1.0.0-rc.10" + "@rolldown/binding-darwin-x64" "1.0.0-rc.10" + "@rolldown/binding-freebsd-x64" "1.0.0-rc.10" + "@rolldown/binding-linux-arm-gnueabihf" "1.0.0-rc.10" + "@rolldown/binding-linux-arm64-gnu" "1.0.0-rc.10" + "@rolldown/binding-linux-arm64-musl" "1.0.0-rc.10" + "@rolldown/binding-linux-ppc64-gnu" "1.0.0-rc.10" + "@rolldown/binding-linux-s390x-gnu" "1.0.0-rc.10" + "@rolldown/binding-linux-x64-gnu" "1.0.0-rc.10" + "@rolldown/binding-linux-x64-musl" "1.0.0-rc.10" + "@rolldown/binding-openharmony-arm64" "1.0.0-rc.10" + "@rolldown/binding-wasm32-wasi" "1.0.0-rc.10" + "@rolldown/binding-win32-arm64-msvc" "1.0.0-rc.10" + "@rolldown/binding-win32-x64-msvc" "1.0.0-rc.10" + rollup-plugin-cleanup@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/rollup-plugin-cleanup/-/rollup-plugin-cleanup-3.2.1.tgz#8cbc92ecf58babd7c210051929797f137bbf777c" @@ -26647,6 +26821,11 @@ rou3@^0.7.12: resolved "https://registry.yarnpkg.com/rou3/-/rou3-0.7.12.tgz#cac17425c04abddba854a42385cabfe0b971a179" integrity sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg== +rou3@^0.8.1: + version "0.8.1" + resolved "https://registry.yarnpkg.com/rou3/-/rou3-0.8.1.tgz#d18c9dae42bdd9cd4fffa77bc6731d5cfe92129a" + integrity sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA== + router@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/router/-/router-2.2.0.tgz#019be620b711c87641167cc79b99090f00b146ef" @@ -27849,10 +28028,10 @@ sqlstring@2.3.1: resolved "https://registry.yarnpkg.com/sqlstring/-/sqlstring-2.3.1.tgz#475393ff9e91479aea62dcaf0ca3d14983a7fb40" integrity sha1-R1OT/56RR5rqYtyvDKPRSYOn+0A= -srvx@^0.11.2: - version "0.11.4" - resolved "https://registry.yarnpkg.com/srvx/-/srvx-0.11.4.tgz#0d1dd962c2320f84fc7872f2500b21c84c3d1b97" - integrity sha512-m/2p87bqWZ94xpRN06qNBwh0xq/D0dXajnvPDSHFqrTogxuTWYNP1UHz6Cf+oY7D+NPLY35TJAp4ESIKn0WArQ== +srvx@^0.11.12, srvx@^0.11.2, srvx@^0.11.9: + version "0.11.12" + resolved "https://registry.yarnpkg.com/srvx/-/srvx-0.11.12.tgz#ed59866cd0cec580b119e161ead3fecd2a546fee" + integrity sha512-AQfrGqntqVPXgP03pvBDN1KyevHC+KmYVqb8vVf4N+aomQqdhaZxjvoVp+AOm4u6x+GgNQY3MVzAUIn+TqwkOA== ssri@^9.0.0: version "9.0.1" @@ -29779,6 +29958,11 @@ unstorage@^1.16.0, unstorage@^1.17.4: ofetch "^1.5.1" ufo "^1.6.3" +unstorage@^2.0.0-alpha.6: + version "2.0.0-alpha.7" + resolved "https://registry.yarnpkg.com/unstorage/-/unstorage-2.0.0-alpha.7.tgz#803ea90176683bf2175bb01065cb07df6d65280a" + integrity sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog== + untildify@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/untildify/-/untildify-2.1.0.tgz#17eb2807987f76952e9c0485fc311d06a826a2e0" From b7f5d0975a07e310f660650e16e31b31af35e68b Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Mon, 23 Mar 2026 11:11:55 +0100 Subject: [PATCH 33/43] fix(core): Send `internal_error` as span status for Vercel error spans (#19921) Follow up to: https://github.com/getsentry/sentry-javascript/pull/19863 Vercel sets raw error messages as span status. Currently we send them as is, but these values should be normalized to a known `SpanStatusType`. I confirmed the behavior with https://github.com/getsentry/testing-ai-sdk-integrations. Closes #19922 (added automatically) --- packages/core/src/tracing/vercel-ai/index.ts | 6 ++++ .../lib/tracing/vercel-ai-span-status.test.ts | 36 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 packages/core/test/lib/tracing/vercel-ai-span-status.test.ts diff --git a/packages/core/src/tracing/vercel-ai/index.ts b/packages/core/src/tracing/vercel-ai/index.ts index 1d8a27e8e3aa..5458ace456c5 100644 --- a/packages/core/src/tracing/vercel-ai/index.ts +++ b/packages/core/src/tracing/vercel-ai/index.ts @@ -266,6 +266,12 @@ function processEndedVercelAiSpan(span: SpanJSON): void { return; } + // The Vercel AI SDK sets span status to raw error message strings. + // Any such value should be normalized to a SpanStatusType value. We pick internal_error as it is the most generic. + if (span.status && span.status !== 'ok') { + span.status = 'internal_error'; + } + renameAttributeKey(attributes, AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE); renameAttributeKey(attributes, AI_USAGE_PROMPT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE); renameAttributeKey(attributes, AI_USAGE_CACHED_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE); diff --git a/packages/core/test/lib/tracing/vercel-ai-span-status.test.ts b/packages/core/test/lib/tracing/vercel-ai-span-status.test.ts new file mode 100644 index 000000000000..dbba343eca42 --- /dev/null +++ b/packages/core/test/lib/tracing/vercel-ai-span-status.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { addVercelAiProcessors } from '../../../src/tracing/vercel-ai'; +import type { SpanJSON } from '../../../src/types-hoist/span'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +describe('vercel-ai span status normalization', () => { + function processSpan(status: string): string | undefined { + const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0 }); + const client = new TestClient(options); + client.init(); + addVercelAiProcessors(client); + + const span: SpanJSON = { + description: 'test', + span_id: 'test-span-id', + trace_id: 'test-trace-id', + start_timestamp: 1000, + timestamp: 2000, + origin: 'auto.vercelai.otel', + status, + data: {}, + }; + + const eventProcessor = client['_eventProcessors'].find(p => p.id === 'VercelAiEventProcessor'); + const processedEvent = eventProcessor!({ type: 'transaction' as const, spans: [span] }, {}); + return (processedEvent as { spans?: SpanJSON[] })?.spans?.[0]?.status; + } + + it('normalizes raw error message status to internal_error', () => { + expect(processSpan("FileNotFoundError: The file '/nonexistent/file.txt' does not exist")).toBe('internal_error'); + }); + + it('preserves ok status', () => { + expect(processSpan('ok')).toBe('ok'); + }); +}); From 907d06ccdfba2b406d5036ef3956c0e06418b6f0 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:40:11 +0100 Subject: [PATCH 34/43] ref(nuxt): Extract core logic for storage/database to prepare for Nuxt v5 (#19920) Following the same pattern established for the H3 event handler, the database and storage instrumentation logic has been extracted from the plugin entry points into shared utility functions (`instrumentDatabase.ts`, `instrumentStorage.ts`). Closes https://github.com/getsentry/sentry-javascript/issues/19916 --------- Co-authored-by: Nicolas Hrubec --- .../runtime/plugins/database-legacy.server.ts | 13 + .../src/runtime/plugins/database.server.ts | 231 +----------- .../runtime/plugins/storage-legacy.server.ts | 12 + .../src/runtime/plugins/storage.server.ts | 312 +--------------- .../src/runtime/utils/database-span-data.ts | 18 +- .../src/runtime/utils/instrumentDatabase.ts | 232 ++++++++++++ .../src/runtime/utils/instrumentStorage.ts | 340 ++++++++++++++++++ packages/nuxt/src/vite/databaseConfig.ts | 2 +- packages/nuxt/src/vite/storageConfig.ts | 2 +- 9 files changed, 626 insertions(+), 536 deletions(-) create mode 100644 packages/nuxt/src/runtime/plugins/database-legacy.server.ts create mode 100644 packages/nuxt/src/runtime/plugins/storage-legacy.server.ts create mode 100644 packages/nuxt/src/runtime/utils/instrumentDatabase.ts create mode 100644 packages/nuxt/src/runtime/utils/instrumentStorage.ts diff --git a/packages/nuxt/src/runtime/plugins/database-legacy.server.ts b/packages/nuxt/src/runtime/plugins/database-legacy.server.ts new file mode 100644 index 000000000000..fc9dca7c964c --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/database-legacy.server.ts @@ -0,0 +1,13 @@ +import type { NitroAppPlugin } from 'nitropack'; +import { useDatabase } from 'nitropack/runtime'; +// @ts-expect-error - This is a virtual module +import { databaseConfig } from '#sentry/database-config.mjs'; +import type { DatabaseConnectionConfig } from '../utils/database-span-data'; +import { createDatabasePlugin } from '../utils/instrumentDatabase'; + +/** + * Nitro plugin that instruments database calls for Nuxt v3/v4 (Nitro v2) + */ +export default (() => { + createDatabasePlugin(useDatabase, databaseConfig as Record); +}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/runtime/plugins/database.server.ts b/packages/nuxt/src/runtime/plugins/database.server.ts index ffdc1fceba18..71c4b6011dae 100644 --- a/packages/nuxt/src/runtime/plugins/database.server.ts +++ b/packages/nuxt/src/runtime/plugins/database.server.ts @@ -1,232 +1,13 @@ -import { - addBreadcrumb, - captureException, - debug, - flushIfServerless, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - type Span, - SPAN_STATUS_ERROR, - startSpan, - type StartSpanOptions, -} from '@sentry/core'; -import type { Database, PreparedStatement } from 'db0'; -import type { NitroAppPlugin } from 'nitropack'; -import { useDatabase } from 'nitropack/runtime'; -import type { DatabaseConnectionConfig as DatabaseConfig } from 'nitropack/types'; +import type { NitroAppPlugin } from 'nitro/types'; +import { useDatabase } from 'nitro/database'; // @ts-expect-error - This is a virtual module import { databaseConfig } from '#sentry/database-config.mjs'; -import { type DatabaseSpanData, getDatabaseSpanData } from '../utils/database-span-data'; - -type MaybeInstrumentedDatabase = Database & { - __sentry_instrumented__?: boolean; -}; - -/** - * Keeps track of prepared statements that have been patched. - */ -const patchedStatement = new WeakSet(); +import type { DatabaseConnectionConfig } from '../utils/database-span-data'; +import { createDatabasePlugin } from '../utils/instrumentDatabase'; /** - * The Sentry origin for the database plugin. - */ -const SENTRY_ORIGIN = 'auto.db.nuxt'; - -/** - * Creates a Nitro plugin that instruments the database calls. + * Nitro plugin that instruments database calls for Nuxt v5+ (Nitro v3+) */ export default (() => { - try { - const _databaseConfig = databaseConfig as Record; - const databaseInstances = Object.keys(databaseConfig); - debug.log('[Nitro Database Plugin]: Instrumenting databases...'); - - for (const instance of databaseInstances) { - debug.log('[Nitro Database Plugin]: Instrumenting database instance:', instance); - const db = useDatabase(instance); - instrumentDatabase(db, _databaseConfig[instance]); - } - - debug.log('[Nitro Database Plugin]: Databases instrumented.'); - } catch (error) { - // During build time, we can't use the useDatabase function, so we just log an error. - if (error instanceof Error && /Cannot access 'instances'/.test(error.message)) { - debug.log('[Nitro Database Plugin]: Database instrumentation skipped during build time.'); - return; - } - - debug.error('[Nitro Database Plugin]: Failed to instrument database:', error); - } + createDatabasePlugin(useDatabase, databaseConfig as Record); }) satisfies NitroAppPlugin; - -/** - * Instruments a database instance with Sentry. - */ -function instrumentDatabase(db: MaybeInstrumentedDatabase, config?: DatabaseConfig): void { - if (db.__sentry_instrumented__) { - debug.log('[Nitro Database Plugin]: Database already instrumented. Skipping...'); - return; - } - - const metadata: DatabaseSpanData = { - 'db.system.name': config?.connector ?? db.dialect, - ...getDatabaseSpanData(config), - }; - - db.prepare = new Proxy(db.prepare, { - apply(target, thisArg, args: Parameters) { - const [query] = args; - - return instrumentPreparedStatement(target.apply(thisArg, args), query, metadata); - }, - }); - - // Sadly the `.sql` template tag doesn't call `db.prepare` internally and it calls the connector's `.prepare` directly - // So we have to patch it manually, and would mean we would have less info in the spans. - // https://github.com/unjs/db0/blob/main/src/database.ts#L64 - db.sql = new Proxy(db.sql, { - apply(target, thisArg, args: Parameters) { - const query = args[0]?.[0] ?? ''; - const opts = createStartSpanOptions(query, metadata); - - return startSpan( - opts, - handleSpanStart(() => target.apply(thisArg, args)), - ); - }, - }); - - db.exec = new Proxy(db.exec, { - apply(target, thisArg, args: Parameters) { - return startSpan( - createStartSpanOptions(args[0], metadata), - handleSpanStart(() => target.apply(thisArg, args), { query: args[0] }), - ); - }, - }); - - db.__sentry_instrumented__ = true; -} - -/** - * Instruments a DB prepared statement with Sentry. - * - * This is meant to be used as a top-level call, under the hood it calls `instrumentPreparedStatementQueries` - * to patch the query methods. The reason for this abstraction is to ensure that the `bind` method is also patched. - */ -function instrumentPreparedStatement( - statement: PreparedStatement, - query: string, - data: DatabaseSpanData, -): PreparedStatement { - // statement.bind() returns a new instance of D1PreparedStatement, so we have to patch it as well. - // eslint-disable-next-line @typescript-eslint/unbound-method - statement.bind = new Proxy(statement.bind, { - apply(target, thisArg, args: Parameters) { - return instrumentPreparedStatementQueries(target.apply(thisArg, args), query, data); - }, - }); - - return instrumentPreparedStatementQueries(statement, query, data); -} - -/** - * Patches the query methods of a DB prepared statement with Sentry. - */ -function instrumentPreparedStatementQueries( - statement: PreparedStatement, - query: string, - data: DatabaseSpanData, -): PreparedStatement { - if (patchedStatement.has(statement)) { - return statement; - } - - // eslint-disable-next-line @typescript-eslint/unbound-method - statement.get = new Proxy(statement.get, { - apply(target, thisArg, args: Parameters) { - return startSpan( - createStartSpanOptions(query, data), - handleSpanStart(() => target.apply(thisArg, args), { query }), - ); - }, - }); - - // eslint-disable-next-line @typescript-eslint/unbound-method - statement.run = new Proxy(statement.run, { - apply(target, thisArg, args: Parameters) { - return startSpan( - createStartSpanOptions(query, data), - handleSpanStart(() => target.apply(thisArg, args), { query }), - ); - }, - }); - - // eslint-disable-next-line @typescript-eslint/unbound-method - statement.all = new Proxy(statement.all, { - apply(target, thisArg, args: Parameters) { - return startSpan( - createStartSpanOptions(query, data), - handleSpanStart(() => target.apply(thisArg, args), { query }), - ); - }, - }); - - patchedStatement.add(statement); - - return statement; -} - -/** - * Creates a span start callback handler - */ -function handleSpanStart(fn: () => unknown, breadcrumbOpts?: { query: string }) { - return async (span: Span) => { - try { - const result = await fn(); - if (breadcrumbOpts) { - createBreadcrumb(breadcrumbOpts.query); - } - - return result; - } catch (error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { - handled: false, - type: SENTRY_ORIGIN, - }, - }); - - // Re-throw the error to be handled by the caller - throw error; - } finally { - await flushIfServerless(); - } - }; -} - -function createBreadcrumb(query: string): void { - addBreadcrumb({ - category: 'query', - message: query, - data: { - 'db.query.text': query, - }, - }); -} - -/** - * Creates a start span options object. - */ -function createStartSpanOptions(query: string, data: DatabaseSpanData): StartSpanOptions { - return { - name: query, - attributes: { - 'db.query.text': query, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', - ...data, - }, - }; -} diff --git a/packages/nuxt/src/runtime/plugins/storage-legacy.server.ts b/packages/nuxt/src/runtime/plugins/storage-legacy.server.ts new file mode 100644 index 000000000000..77b6a390d0e9 --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/storage-legacy.server.ts @@ -0,0 +1,12 @@ +import type { NitroAppPlugin } from 'nitropack'; +import { useStorage } from 'nitropack/runtime'; +// @ts-expect-error - This is a virtual module +import { userStorageMounts } from '#sentry/storage-config.mjs'; +import { createStoragePlugin } from '../utils/instrumentStorage'; + +/** + * Nitro plugin that instruments storage driver calls for Nuxt v3/v4 (Nitro v2) + */ +export default (async _nitroApp => { + await createStoragePlugin(useStorage, userStorageMounts as string[]); +}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/runtime/plugins/storage.server.ts b/packages/nuxt/src/runtime/plugins/storage.server.ts index 1c5a3fd678d4..f9763b51d47b 100644 --- a/packages/nuxt/src/runtime/plugins/storage.server.ts +++ b/packages/nuxt/src/runtime/plugins/storage.server.ts @@ -1,314 +1,12 @@ -import { - captureException, - debug, - flushIfServerless, - SEMANTIC_ATTRIBUTE_CACHE_HIT, - SEMANTIC_ATTRIBUTE_CACHE_KEY, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SPAN_STATUS_ERROR, - SPAN_STATUS_OK, - type SpanAttributes, - startSpan, - type StartSpanOptions, -} from '@sentry/core'; -import type { NitroAppPlugin } from 'nitropack'; -import { useStorage } from 'nitropack/runtime'; -import type { CacheEntry, ResponseCacheEntry } from 'nitropack/types'; -import type { Driver, Storage } from 'unstorage'; +import type { NitroAppPlugin } from 'nitro/types'; +import { useStorage } from 'nitro/storage'; // @ts-expect-error - This is a virtual module import { userStorageMounts } from '#sentry/storage-config.mjs'; - -type MaybeInstrumented = T & { - __sentry_instrumented__?: boolean; -}; - -type MaybeInstrumentedDriver = MaybeInstrumented; - -type DriverMethod = keyof Driver; +import { createStoragePlugin } from '../utils/instrumentStorage'; /** - * Methods that should have a attribute to indicate a cache hit. - */ -const CACHE_HIT_METHODS = new Set(['hasItem', 'getItem', 'getItemRaw']); - -/** - * Creates a Nitro plugin that instruments the storage driver. + * Nitro plugin that instruments storage driver calls for Nuxt v5+ (Nitro v3+) */ export default (async _nitroApp => { - // This runs at runtime when the Nitro server starts - const storage = useStorage(); - // Mounts are suffixed with a colon, so we need to add it to the set items - const userMounts = new Set((userStorageMounts as string[]).map(m => `${m}:`)); - - debug.log('[storage] Starting to instrument storage drivers...'); - - // Adds cache mount to handle Nitro's cache calls - // Nitro uses the mount to cache functions and event handlers - // https://nitro.build/guide/cache - userMounts.add('cache:'); - // In production, unless the user configured a specific cache driver, Nitro will use the memory driver at root mount. - // Either way, we need to instrument the root mount as well. - userMounts.add(''); - - // Get all mounted storage drivers - const mounts = storage.getMounts(); - for (const mount of mounts) { - // Skip excluded mounts and root mount - if (!userMounts.has(mount.base)) { - continue; - } - - instrumentDriver(mount.driver, mount.base); - } - - // Wrap the mount method to instrument future mounts - storage.mount = wrapStorageMount(storage); + await createStoragePlugin(useStorage, userStorageMounts as string[]); }) satisfies NitroAppPlugin; - -/** - * Instruments a driver by wrapping all method calls using proxies. - */ -function instrumentDriver(driver: MaybeInstrumentedDriver, mountBase: string): Driver { - // Already instrumented, skip... - if (driver.__sentry_instrumented__) { - debug.log(`[storage] Driver already instrumented: "${driver.name}". Skipping...`); - - return driver; - } - - debug.log(`[storage] Instrumenting driver: "${driver.name}" on mount: "${mountBase}"`); - - // List of driver methods to instrument - // get/set/remove are aliases and already use their {method}Item methods - const methodsToInstrument: DriverMethod[] = [ - 'hasItem', - 'getItem', - 'getItemRaw', - 'getItems', - 'setItem', - 'setItemRaw', - 'setItems', - 'removeItem', - 'getKeys', - 'clear', - ]; - - for (const methodName of methodsToInstrument) { - const original = driver[methodName]; - // Skip if method doesn't exist on this driver - if (typeof original !== 'function') { - continue; - } - - // Replace with instrumented - driver[methodName] = createMethodWrapper(original, methodName, driver, mountBase); - } - - // Mark as instrumented - driver.__sentry_instrumented__ = true; - - return driver; -} - -/** - * Creates an instrumented method for the given method. - */ -function createMethodWrapper( - original: (...args: unknown[]) => unknown, - methodName: DriverMethod, - driver: Driver, - mountBase: string, -): (...args: unknown[]) => unknown { - return new Proxy(original, { - async apply(target, thisArg, args) { - const options = createSpanStartOptions(methodName, driver, mountBase, args); - - debug.log(`[storage] Running method: "${methodName}" on driver: "${driver.name ?? 'unknown'}"`); - - return startSpan(options, async span => { - try { - const result = await target.apply(thisArg, args); - span.setStatus({ code: SPAN_STATUS_OK }); - - if (CACHE_HIT_METHODS.has(methodName)) { - span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, isCacheHit(args[0], result)); - } - - return result; - } catch (error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { - handled: false, - type: options.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN], - }, - }); - - // Re-throw the error to be handled by the caller - throw error; - } finally { - await flushIfServerless(); - } - }); - }, - }); -} - -/** - * Wraps the storage mount method to instrument the driver. - */ -function wrapStorageMount(storage: Storage): Storage['mount'] { - const original: MaybeInstrumented = storage.mount; - if (original.__sentry_instrumented__) { - return original; - } - - function mountWithInstrumentation(base: string, driver: Driver): Storage { - debug.log(`[storage] Instrumenting mount: "${base}"`); - - const instrumentedDriver = instrumentDriver(driver, base); - - return original(base, instrumentedDriver); - } - - mountWithInstrumentation.__sentry_instrumented__ = true; - - return mountWithInstrumentation; -} -/** - * Normalizes the method name to snake_case to be used in span names or op. - */ -function normalizeMethodName(methodName: string): string { - return methodName.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); -} - -/** - * Checks if the value is empty, used for cache hit detection. - */ -function isEmptyValue(value: unknown): value is null | undefined { - return value === null || value === undefined; -} - -/** - * Creates the span start options for the storage method. - */ -function createSpanStartOptions( - methodName: keyof Driver, - driver: Driver, - mountBase: string, - args: unknown[], -): StartSpanOptions { - const keys = getCacheKeys(args?.[0], mountBase); - - const attributes: SpanAttributes = { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `cache.${normalizeMethodName(methodName)}`, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', - [SEMANTIC_ATTRIBUTE_CACHE_KEY]: keys.length > 1 ? keys : keys[0], - 'db.operation.name': methodName, - 'db.collection.name': mountBase.replace(/:$/, ''), - 'db.system.name': driver.name ?? 'unknown', - }; - - return { - name: keys.join(', '), - attributes, - }; -} - -/** - * Gets a normalized array of cache keys. - */ -function getCacheKeys(key: unknown, prefix: string): string[] { - // Handles an array of keys - if (Array.isArray(key)) { - return key.map(k => normalizeKey(k, prefix)); - } - - return [normalizeKey(key, prefix)]; -} - -/** - * Normalizes the key to a string for `cache.key` attribute. - */ -function normalizeKey(key: unknown, prefix: string): string { - if (typeof key === 'string') { - return `${prefix}${key}`; - } - - // Handles an object with a key property - if (typeof key === 'object' && key !== null && 'key' in key) { - return `${prefix}${key.key}`; - } - - return `${prefix}${isEmptyValue(key) ? '' : String(key)}`; -} - -const CACHED_FN_HANDLERS_RE = /^nitro:(functions|handlers):/i; - -/** - * Since Nitro's cache may not utilize the driver's TTL, it is possible that the value is present in the cache but won't be used by Nitro. - * The maxAge and expires values are serialized by Nitro in the cache entry. This means the value presence does not necessarily mean a cache hit. - * So in order to properly report cache hits for `defineCachedFunction` and `defineCachedEventHandler` we need to check the cached value ourselves. - * First we check if the key matches the `defineCachedFunction` or `defineCachedEventHandler` key patterns, and if so we check the cached value. - */ -function isCacheHit(key: string, value: unknown): boolean { - try { - const isEmpty = isEmptyValue(value); - // Empty value means no cache hit either way - // Or if key doesn't match the cached function or handler patterns, we can return the empty value check - if (isEmpty || !CACHED_FN_HANDLERS_RE.test(key)) { - return !isEmpty; - } - - return validateCacheEntry(key, JSON.parse(String(value)) as CacheEntry); - } catch { - // this is a best effort, so we return false if we can't validate the cache entry - return false; - } -} - -/** - * Validates the cache entry. - */ -function validateCacheEntry( - key: string, - entry: CacheEntry | CacheEntry, -): boolean { - if (isEmptyValue(entry.value)) { - return false; - } - - // Date.now is used by Nitro internally, so safe to use here. - // https://github.com/nitrojs/nitro/blob/5508f71b77730e967fb131de817725f5aa7c4862/src/runtime/internal/cache.ts#L78 - if (Date.now() > (entry.expires || 0)) { - return false; - } - - /** - * Pulled from Nitro's cache entry validation - * https://github.com/nitrojs/nitro/blob/5508f71b77730e967fb131de817725f5aa7c4862/src/runtime/internal/cache.ts#L223-L241 - */ - if (isResponseCacheEntry(key, entry)) { - if (entry.value.status >= 400) { - return false; - } - - if (entry.value.body === undefined) { - return false; - } - - if (entry.value.headers.etag === 'undefined' || entry.value.headers['last-modified'] === 'undefined') { - return false; - } - } - - return true; -} - -/** - * Checks if the cache entry is a response cache entry. - */ -function isResponseCacheEntry(key: string, _: CacheEntry): _ is CacheEntry { - return key.startsWith('nitro:handlers:'); -} diff --git a/packages/nuxt/src/runtime/utils/database-span-data.ts b/packages/nuxt/src/runtime/utils/database-span-data.ts index e5d9c8dc7cec..d69368c92e1e 100644 --- a/packages/nuxt/src/runtime/utils/database-span-data.ts +++ b/packages/nuxt/src/runtime/utils/database-span-data.ts @@ -1,14 +1,28 @@ import type { ConnectorName } from 'db0'; -import type { DatabaseConnectionConfig as DatabaseConfig } from 'nitropack/types'; export interface DatabaseSpanData { [key: string]: string | number | undefined; } +/** + * A minimal database connection configuration type compatible with both nitropack (Nitro v2) and nitro (Nitro v3+). + * Mirrors the shape of `DatabaseConnectionConfig` from both packages. + */ +export interface DatabaseConnectionConfig { + connector?: ConnectorName; + options?: { + host?: string; + port?: number; + dataDir?: string; + name?: string; + [key: string]: unknown; + }; +} + /** * Extracts span attributes from the database configuration. */ -export function getDatabaseSpanData(config?: DatabaseConfig): Partial { +export function getDatabaseSpanData(config?: DatabaseConnectionConfig): Partial { try { if (!config?.connector) { // Default to SQLite if no connector is configured diff --git a/packages/nuxt/src/runtime/utils/instrumentDatabase.ts b/packages/nuxt/src/runtime/utils/instrumentDatabase.ts new file mode 100644 index 000000000000..9f7d320fe390 --- /dev/null +++ b/packages/nuxt/src/runtime/utils/instrumentDatabase.ts @@ -0,0 +1,232 @@ +import { + addBreadcrumb, + captureException, + debug, + flushIfServerless, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + type Span, + SPAN_STATUS_ERROR, + startSpan, + type StartSpanOptions, +} from '@sentry/core'; +import type { Database, PreparedStatement } from 'db0'; +import { type DatabaseConnectionConfig, type DatabaseSpanData, getDatabaseSpanData } from './database-span-data'; + +type MaybeInstrumentedDatabase = Database & { + __sentry_instrumented__?: boolean; +}; + +/** + * Keeps track of prepared statements that have been patched. + */ +const patchedStatement = new WeakSet(); + +/** + * The Sentry origin for the database plugin. + */ +const SENTRY_ORIGIN = 'auto.db.nuxt'; + +/** + * Creates the Nitro database plugin setup by instrumenting the configured database instances. + * + * Called from the version-specific plugin entry points (database.server.ts / database-legacy.server.ts) + * which supply the correct `useDatabase` import for their respective Nitro version. + */ +export function createDatabasePlugin( + useDatabase: (name: string) => Database, + databaseConfig: Record, +): void { + try { + const databaseInstances = Object.keys(databaseConfig); + debug.log('[Nitro Database Plugin]: Instrumenting databases...'); + + for (const instance of databaseInstances) { + debug.log('[Nitro Database Plugin]: Instrumenting database instance:', instance); + const db = useDatabase(instance); + instrumentDatabase(db, databaseConfig[instance]); + } + + debug.log('[Nitro Database Plugin]: Databases instrumented.'); + } catch (error) { + // During build time, we can't use the useDatabase function, so we just log an error. + if (error instanceof Error && /Cannot access 'instances'/.test(error.message)) { + debug.log('[Nitro Database Plugin]: Database instrumentation skipped during build time.'); + return; + } + + debug.error('[Nitro Database Plugin]: Failed to instrument database:', error); + } +} + +/** + * Instruments a database instance with Sentry. + */ +function instrumentDatabase(db: MaybeInstrumentedDatabase, config?: DatabaseConnectionConfig): void { + if (db.__sentry_instrumented__) { + debug.log('[Nitro Database Plugin]: Database already instrumented. Skipping...'); + return; + } + + const metadata: DatabaseSpanData = { + 'db.system.name': config?.connector ?? db.dialect, + ...getDatabaseSpanData(config), + }; + + db.prepare = new Proxy(db.prepare, { + apply(target, thisArg, args: Parameters) { + const [query] = args; + + return instrumentPreparedStatement(target.apply(thisArg, args), query, metadata); + }, + }); + + // Sadly the `.sql` template tag doesn't call `db.prepare` internally and it calls the connector's `.prepare` directly + // So we have to patch it manually, and would mean we would have less info in the spans. + // https://github.com/unjs/db0/blob/main/src/database.ts#L64 + db.sql = new Proxy(db.sql, { + apply(target, thisArg, args: Parameters) { + const query = args[0]?.[0] ?? ''; + const opts = createStartSpanOptions(query, metadata); + + return startSpan( + opts, + handleSpanStart(() => target.apply(thisArg, args)), + ); + }, + }); + + db.exec = new Proxy(db.exec, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(args[0], metadata), + handleSpanStart(() => target.apply(thisArg, args), { query: args[0] }), + ); + }, + }); + + db.__sentry_instrumented__ = true; +} + +/** + * Instruments a DB prepared statement with Sentry. + * + * This is meant to be used as a top-level call, under the hood it calls `instrumentPreparedStatementQueries` + * to patch the query methods. The reason for this abstraction is to ensure that the `bind` method is also patched. + */ +function instrumentPreparedStatement( + statement: PreparedStatement, + query: string, + data: DatabaseSpanData, +): PreparedStatement { + // statement.bind() returns a new instance of D1PreparedStatement, so we have to patch it as well. + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.bind = new Proxy(statement.bind, { + apply(target, thisArg, args: Parameters) { + return instrumentPreparedStatementQueries(target.apply(thisArg, args), query, data); + }, + }); + + return instrumentPreparedStatementQueries(statement, query, data); +} + +/** + * Patches the query methods of a DB prepared statement with Sentry. + */ +function instrumentPreparedStatementQueries( + statement: PreparedStatement, + query: string, + data: DatabaseSpanData, +): PreparedStatement { + if (patchedStatement.has(statement)) { + return statement; + } + + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.get = new Proxy(statement.get, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(query, data), + handleSpanStart(() => target.apply(thisArg, args), { query }), + ); + }, + }); + + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.run = new Proxy(statement.run, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(query, data), + handleSpanStart(() => target.apply(thisArg, args), { query }), + ); + }, + }); + + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.all = new Proxy(statement.all, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(query, data), + handleSpanStart(() => target.apply(thisArg, args), { query }), + ); + }, + }); + + patchedStatement.add(statement); + + return statement; +} + +/** + * Creates a span start callback handler. + */ +function handleSpanStart(fn: () => unknown, breadcrumbOpts?: { query: string }) { + return async (span: Span) => { + try { + const result = await fn(); + if (breadcrumbOpts) { + createBreadcrumb(breadcrumbOpts.query); + } + + return result; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: SENTRY_ORIGIN, + }, + }); + + // Re-throw the error to be handled by the caller + throw error; + } finally { + await flushIfServerless(); + } + }; +} + +function createBreadcrumb(query: string): void { + addBreadcrumb({ + category: 'query', + message: query, + data: { + 'db.query.text': query, + }, + }); +} + +/** + * Creates a start span options object. + */ +function createStartSpanOptions(query: string, data: DatabaseSpanData): StartSpanOptions { + return { + name: query, + attributes: { + 'db.query.text': query, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', + ...data, + }, + }; +} diff --git a/packages/nuxt/src/runtime/utils/instrumentStorage.ts b/packages/nuxt/src/runtime/utils/instrumentStorage.ts new file mode 100644 index 000000000000..e51666aba79b --- /dev/null +++ b/packages/nuxt/src/runtime/utils/instrumentStorage.ts @@ -0,0 +1,340 @@ +import { + captureException, + debug, + flushIfServerless, + SEMANTIC_ATTRIBUTE_CACHE_HIT, + SEMANTIC_ATTRIBUTE_CACHE_KEY, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + SPAN_STATUS_OK, + type SpanAttributes, + startSpan, + type StartSpanOptions, +} from '@sentry/core'; +import type { Driver, Storage } from 'unstorage'; + +/** + * A minimal cache entry type compatible with both nitropack (Nitro v2) and nitro (Nitro v3+). + * Mirrors the shape of `CacheEntry` from both packages. + */ +interface CacheEntry { + value?: T; + expires?: number; +} + +/** + * A minimal response cache entry type compatible with both nitropack (Nitro v2) and nitro (Nitro v3+). + * Mirrors the shape of `ResponseCacheEntry` from both packages. + */ +interface ResponseCacheEntry { + status?: number; + body?: unknown; + headers?: Record; +} + +/** + * The Nitro-specific Storage interface that extends unstorage's Storage with `getMounts`. + * Both nitropack and nitro expose a storage with this shape at runtime. + */ +interface NitroStorage extends Storage { + getMounts(): Array<{ base: string; driver: Driver }>; +} + +type MaybeInstrumented = T & { + __sentry_instrumented__?: boolean; +}; + +type MaybeInstrumentedDriver = MaybeInstrumented; + +type DriverMethod = keyof Driver; + +/** + * Methods that should have an attribute to indicate a cache hit. + */ +const CACHE_HIT_METHODS = new Set(['hasItem', 'getItem', 'getItemRaw']); + +/** + * Creates the Nitro storage plugin setup by instrumenting all relevant storage drivers. + * + * The `useStorage` parameter is typed as `() => unknown` because nitropack (Nitro v2) and nitro (Nitro v3+) define `StorageValue` differently. + * Cast to `NitroStorage` is safe since all Nitro versions expose `getMounts()` at runtime. + */ +export async function createStoragePlugin(useStorage: () => unknown, userStorageMounts: string[]): Promise { + // This runs at runtime when the Nitro server starts + const storage = useStorage() as NitroStorage; + // Mounts are suffixed with a colon, so we need to add it to the set items + const userMounts = new Set(userStorageMounts.map(m => `${m}:`)); + + debug.log('[storage] Starting to instrument storage drivers...'); + + // Adds cache mount to handle Nitro's cache calls + // Nitro uses the mount to cache functions and event handlers + // https://nitro.build/guide/cache + userMounts.add('cache:'); + // In production, unless the user configured a specific cache driver, Nitro will use the memory driver at root mount. + // Either way, we need to instrument the root mount as well. + userMounts.add(''); + + // Get all mounted storage drivers + const mounts = storage.getMounts(); + for (const mount of mounts) { + // Skip excluded mounts and root mount + if (!userMounts.has(mount.base)) { + continue; + } + + instrumentDriver(mount.driver, mount.base); + } + + // Wrap the mount method to instrument future mounts + storage.mount = wrapStorageMount(storage); +} + +/** + * Instruments a driver by wrapping all method calls using proxies. + */ +function instrumentDriver(driver: MaybeInstrumentedDriver, mountBase: string): Driver { + // Already instrumented, skip... + if (driver.__sentry_instrumented__) { + debug.log(`[storage] Driver already instrumented: "${driver.name}". Skipping...`); + + return driver; + } + + debug.log(`[storage] Instrumenting driver: "${driver.name}" on mount: "${mountBase}"`); + + // List of driver methods to instrument + // get/set/remove are aliases and already use their {method}Item methods + const methodsToInstrument: DriverMethod[] = [ + 'hasItem', + 'getItem', + 'getItemRaw', + 'getItems', + 'setItem', + 'setItemRaw', + 'setItems', + 'removeItem', + 'getKeys', + 'clear', + ]; + + for (const methodName of methodsToInstrument) { + const original = driver[methodName]; + // Skip if method doesn't exist on this driver + if (typeof original !== 'function') { + continue; + } + + // Replace with instrumented + driver[methodName] = createMethodWrapper(original, methodName, driver, mountBase); + } + + // Mark as instrumented + driver.__sentry_instrumented__ = true; + + return driver; +} + +/** + * Creates an instrumented method for the given method. + */ +function createMethodWrapper( + original: (...args: unknown[]) => unknown, + methodName: DriverMethod, + driver: Driver, + mountBase: string, +): (...args: unknown[]) => unknown { + return new Proxy(original, { + async apply(target, thisArg, args) { + const options = createSpanStartOptions(methodName, driver, mountBase, args); + + debug.log(`[storage] Running method: "${methodName}" on driver: "${driver.name ?? 'unknown'}"`); + + return startSpan(options, async span => { + try { + const result = await target.apply(thisArg, args); + span.setStatus({ code: SPAN_STATUS_OK }); + + if (CACHE_HIT_METHODS.has(methodName)) { + span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, isCacheHit(args[0], result)); + } + + return result; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: options.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN], + }, + }); + + // Re-throw the error to be handled by the caller + throw error; + } finally { + await flushIfServerless(); + } + }); + }, + }); +} + +/** + * Wraps the storage mount method to instrument the driver on future mounts. + */ +function wrapStorageMount(storage: Storage): Storage['mount'] { + const original: MaybeInstrumented = storage.mount; + if (original.__sentry_instrumented__) { + return original; + } + + function mountWithInstrumentation(base: string, driver: Driver): Storage { + debug.log(`[storage] Instrumenting mount: "${base}"`); + + const instrumentedDriver = instrumentDriver(driver, base); + + return original(base, instrumentedDriver); + } + + mountWithInstrumentation.__sentry_instrumented__ = true; + + return mountWithInstrumentation; +} + +/** + * Normalizes the method name to snake_case to be used in span names or op. + */ +function normalizeMethodName(methodName: string): string { + return methodName.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); +} + +/** + * Checks if the value is empty, used for cache hit detection. + */ +function isEmptyValue(value: unknown): value is null | undefined { + return value === null || value === undefined; +} + +/** + * Creates the span start options for the storage method. + */ +function createSpanStartOptions( + methodName: keyof Driver, + driver: Driver, + mountBase: string, + args: unknown[], +): StartSpanOptions { + const keys = getCacheKeys(args?.[0], mountBase); + + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `cache.${normalizeMethodName(methodName)}`, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: keys.length > 1 ? keys : keys[0], + 'db.operation.name': methodName, + 'db.collection.name': mountBase.replace(/:$/, ''), + 'db.system.name': driver.name ?? 'unknown', + }; + + return { + name: keys.join(', '), + attributes, + }; +} + +/** + * Gets a normalized array of cache keys. + */ +function getCacheKeys(key: unknown, prefix: string): string[] { + // Handles an array of keys + if (Array.isArray(key)) { + return key.map(k => normalizeKey(k, prefix)); + } + + return [normalizeKey(key, prefix)]; +} + +/** + * Normalizes the key to a string for `cache.key` attribute. + */ +function normalizeKey(key: unknown, prefix: string): string { + if (typeof key === 'string') { + return `${prefix}${key}`; + } + + // Handles an object with a key property + if (typeof key === 'object' && key !== null && 'key' in key) { + return `${prefix}${key.key}`; + } + + return `${prefix}${isEmptyValue(key) ? '' : String(key)}`; +} + +const CACHED_FN_HANDLERS_RE = /^nitro:(functions|handlers):/i; + +/** + * Since Nitro's cache may not utilize the driver's TTL, it is possible that the value is present in the cache but won't be used by Nitro. + * The maxAge and expires values are serialized by Nitro in the cache entry. This means the value presence does not necessarily mean a cache hit. + * So in order to properly report cache hits for `defineCachedFunction` and `defineCachedEventHandler` we need to check the cached value ourselves. + * First we check if the key matches the `defineCachedFunction` or `defineCachedEventHandler` key patterns, and if so we check the cached value. + */ +function isCacheHit(key: unknown, value: unknown): boolean { + try { + const isEmpty = isEmptyValue(value); + // Empty value means no cache hit either way + // Or if key doesn't match the cached function or handler patterns, we can return the empty value check + if (isEmpty || typeof key !== 'string' || !CACHED_FN_HANDLERS_RE.test(key)) { + return !isEmpty; + } + + return validateCacheEntry(key, JSON.parse(String(value)) as CacheEntry); + } catch { + // this is a best effort, so we return false if we can't validate the cache entry + return false; + } +} + +/** + * Validates the cache entry. + */ +function validateCacheEntry( + key: string, + entry: CacheEntry | CacheEntry, +): boolean { + if (isEmptyValue(entry.value)) { + return false; + } + + // Date.now is used by Nitro internally, so safe to use here. + // https://github.com/nitrojs/nitro/blob/5508f71b77730e967fb131de817725f5aa7c4862/src/runtime/internal/cache.ts#L78 + if (Date.now() > (entry.expires || 0)) { + return false; + } + + /** + * Pulled from Nitro's cache entry validation + * https://github.com/nitrojs/nitro/blob/5508f71b77730e967fb131de817725f5aa7c4862/src/runtime/internal/cache.ts#L223-L241 + */ + if (isResponseCacheEntry(key, entry)) { + if ((entry.value.status ?? 0) >= 400) { + return false; + } + + if (entry.value.body === undefined) { + return false; + } + + if (entry.value.headers?.etag === 'undefined' || entry.value.headers?.['last-modified'] === 'undefined') { + return false; + } + } + + return true; +} + +/** + * Checks if the cache entry is a response cache entry. + */ +function isResponseCacheEntry(key: string, _: CacheEntry): _ is CacheEntry { + return key.startsWith('nitro:handlers:'); +} diff --git a/packages/nuxt/src/vite/databaseConfig.ts b/packages/nuxt/src/vite/databaseConfig.ts index c806a8f662c2..5b8bf008421d 100644 --- a/packages/nuxt/src/vite/databaseConfig.ts +++ b/packages/nuxt/src/vite/databaseConfig.ts @@ -38,5 +38,5 @@ export function addDatabaseInstrumentation(nitro: NitroConfig, moduleOptions?: S }, }); - addServerPlugin(createResolver(import.meta.url).resolve('./runtime/plugins/database.server')); + addServerPlugin(createResolver(import.meta.url).resolve('./runtime/plugins/database-legacy.server')); } diff --git a/packages/nuxt/src/vite/storageConfig.ts b/packages/nuxt/src/vite/storageConfig.ts index f4f4004d1b50..87f843f05796 100644 --- a/packages/nuxt/src/vite/storageConfig.ts +++ b/packages/nuxt/src/vite/storageConfig.ts @@ -17,5 +17,5 @@ export function addStorageInstrumentation(nuxt: Nuxt): void { }, }); - addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/storage.server')); + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/storage-legacy.server')); } From 66b48debd4b778e7a3c063a701460620b9f9eb49 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 23 Mar 2026 13:11:23 +0100 Subject: [PATCH 35/43] fix(deps): update lockfile to resolve h3@1.15.10 (#19933) Fixes Dependabot alerts #1236 (SSE Event Injection) and #1237 (Path Traversal via Double Decoding) by updating the yarn.lock resolution for h3 from 1.15.5 to 1.15.10. Easier fix than https://github.com/getsentry/sentry-javascript/pull/19910 as Nuxt 3.21.x introduced breaking type changes (removed nitro from NuxtOptions and nitro:config from NuxtHooks). Co-authored-by: Claude Sonnet 4 --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index dd7f94d7402c..da64ae001539 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18501,9 +18501,9 @@ h3@1.15.3: uncrypto "^0.1.3" h3@^1.10.0, h3@^1.12.0, h3@^1.15.3, h3@^1.15.5: - version "1.15.5" - resolved "https://registry.yarnpkg.com/h3/-/h3-1.15.5.tgz#e2f28d4a66a249973bb050eaddb06b9ab55506f8" - integrity sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg== + version "1.15.10" + resolved "https://registry.yarnpkg.com/h3/-/h3-1.15.10.tgz#defe650df7b70cf585d2020c4146fb580cfb0d42" + integrity sha512-YzJeWSkDZxAhvmp8dexjRK5hxziRO7I9m0N53WhvYL5NiWfkUkzssVzY9jvGu0HBoLFW6+duYmNSn6MaZBCCtg== dependencies: cookie-es "^1.2.2" crossws "^0.3.5" From 51e2cee5a1a24576f3809fdb01118406164d63c7 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 24 Mar 2026 15:04:27 +0100 Subject: [PATCH 36/43] fix(core): Truncate content array format in Vercel (#19911) Add truncation for content array messages i.e. messages that have a `content` key, where `content` is an array of objects e.g. `{"type": "text", "text": "some string"}`. Previously these were returned as is bypassing the truncation logic. This PR makes sure these messages get truncated as well. We already handled parts array messages, which have essentially the same format but use a `parts` key. So I basically just generalized the `truncatePartsMessage` to also handle the content array format. Note: After switching to the Span V2 protocol we will get rid of truncation in the SDK altogether, but for now we should make sure to properly truncate all formats. Closes #19919 (added automatically) --- .../core/src/tracing/ai/messageTruncation.ts | 115 +++++++++++------- .../lib/tracing/ai-message-truncation.test.ts | 61 ++++++++++ 2 files changed, 133 insertions(+), 43 deletions(-) diff --git a/packages/core/src/tracing/ai/messageTruncation.ts b/packages/core/src/tracing/ai/messageTruncation.ts index 16df3c298466..779cf332855b 100644 --- a/packages/core/src/tracing/ai/messageTruncation.ts +++ b/packages/core/src/tracing/ai/messageTruncation.ts @@ -14,15 +14,20 @@ type ContentMessage = { content: string; }; +/** + * One block inside OpenAI / Anthropic `content: [...]` arrays (text, image_url, etc.). + */ +type ContentArrayBlock = { + [key: string]: unknown; + type: string; +}; + /** * Message format used by OpenAI and Anthropic APIs for media. */ type ContentArrayMessage = { [key: string]: unknown; - content: { - [key: string]: unknown; - type: string; - }[]; + content: ContentArrayBlock[]; }; /** @@ -47,6 +52,11 @@ type MediaPart = { content: string; }; +/** + * One element of an array-based message: OpenAI/Anthropic `content[]` or Google `parts`. + */ +type ArrayMessageItem = TextPart | MediaPart | ContentArrayBlock; + /** * Calculate the UTF-8 byte length of a string. */ @@ -95,31 +105,33 @@ function truncateTextByBytes(text: string, maxBytes: number): string { } /** - * Extract text content from a Google GenAI message part. - * Parts are either plain strings or objects with a text property. + * Extract text content from a message item. + * Handles plain strings and objects with a text property. * * @returns The text content */ -function getPartText(part: TextPart | MediaPart): string { - if (typeof part === 'string') { - return part; +function getItemText(item: ArrayMessageItem): string { + if (typeof item === 'string') { + return item; + } + if ('text' in item && typeof item.text === 'string') { + return item.text; } - if ('text' in part) return part.text; return ''; } /** - * Create a new part with updated text content while preserving the original structure. + * Create a new item with updated text content while preserving the original structure. * - * @param part - Original part (string or object) + * @param item - Original item (string or object) * @param text - New text content - * @returns New part with updated text + * @returns New item with updated text */ -function withPartText(part: TextPart | MediaPart, text: string): TextPart { - if (typeof part === 'string') { +function withItemText(item: ArrayMessageItem, text: string): ArrayMessageItem { + if (typeof item === 'string') { return text; } - return { ...part, text }; + return { ...item, text }; } /** @@ -176,56 +188,78 @@ function truncateContentMessage(message: ContentMessage, maxBytes: number): unkn } /** - * Truncate a message with `parts: [...]` format (Google GenAI). - * Keeps as many complete parts as possible, only truncating the first part if needed. + * Extracts the array items and their key from an array-based message. + * Returns `null` key if neither `parts` nor `content` is a valid array. + */ +function getArrayItems(message: PartsMessage | ContentArrayMessage): { + key: 'parts' | 'content' | null; + items: ArrayMessageItem[]; +} { + if ('parts' in message && Array.isArray(message.parts)) { + return { key: 'parts', items: message.parts }; + } + if ('content' in message && Array.isArray(message.content)) { + return { key: 'content', items: message.content }; + } + return { key: null, items: [] }; +} + +/** + * Truncate a message with an array-based format. + * Handles both `parts: [...]` (Google GenAI) and `content: [...]` (OpenAI/Anthropic multimodal). + * Keeps as many complete items as possible, only truncating the first item if needed. * - * @param message - Message with parts array + * @param message - Message with parts or content array * @param maxBytes - Maximum byte limit * @returns Array with truncated message, or empty array if it doesn't fit */ -function truncatePartsMessage(message: PartsMessage, maxBytes: number): unknown[] { - const { parts } = message; +function truncateArrayMessage(message: PartsMessage | ContentArrayMessage, maxBytes: number): unknown[] { + const { key, items } = getArrayItems(message); - // Calculate overhead by creating empty text parts - const emptyParts = parts.map(part => withPartText(part, '')); - const overhead = jsonBytes({ ...message, parts: emptyParts }); + if (key === null || items.length === 0) { + return []; + } + + // Calculate overhead by creating empty text items + const emptyItems = items.map(item => withItemText(item, '')); + const overhead = jsonBytes({ ...message, [key]: emptyItems }); let remainingBytes = maxBytes - overhead; if (remainingBytes <= 0) { return []; } - // Include parts until we run out of space - const includedParts: (TextPart | MediaPart)[] = []; + // Include items until we run out of space + const includedItems: ArrayMessageItem[] = []; - for (const part of parts) { - const text = getPartText(part); + for (const item of items) { + const text = getItemText(item); const textSize = utf8Bytes(text); if (textSize <= remainingBytes) { - // Part fits: include it as-is - includedParts.push(part); + // Item fits: include it as-is + includedItems.push(item); remainingBytes -= textSize; - } else if (includedParts.length === 0) { - // First part doesn't fit: truncate it + } else if (includedItems.length === 0) { + // First item doesn't fit: truncate it const truncated = truncateTextByBytes(text, remainingBytes); if (truncated) { - includedParts.push(withPartText(part, truncated)); + includedItems.push(withItemText(item, truncated)); } break; } else { - // Subsequent part doesn't fit: stop here + // Subsequent item doesn't fit: stop here break; } } /* c8 ignore start * for type safety only, algorithm guarantees SOME text included */ - if (includedParts.length <= 0) { + if (includedItems.length <= 0) { return []; } else { /* c8 ignore stop */ - return [{ ...message, parts: includedParts }]; + return [{ ...message, [key]: includedItems }]; } } @@ -258,13 +292,8 @@ function truncateSingleMessage(message: unknown, maxBytes: number): unknown[] { return truncateContentMessage(message, maxBytes); } - if (isContentArrayMessage(message)) { - // Content array messages are returned as-is without truncation - return [message]; - } - - if (isPartsMessage(message)) { - return truncatePartsMessage(message, maxBytes); + if (isContentArrayMessage(message) || isPartsMessage(message)) { + return truncateArrayMessage(message, maxBytes); } // Unknown message format: cannot truncate safely diff --git a/packages/core/test/lib/tracing/ai-message-truncation.test.ts b/packages/core/test/lib/tracing/ai-message-truncation.test.ts index c7f8e0043622..f1f318e02128 100644 --- a/packages/core/test/lib/tracing/ai-message-truncation.test.ts +++ b/packages/core/test/lib/tracing/ai-message-truncation.test.ts @@ -547,5 +547,66 @@ describe('message truncation utilities', () => { }, ]); }); + + it('truncates content array message when first text item does not fit', () => { + const messages = [ + { + role: 'user', + content: [{ type: 'text', text: `2 ${humongous}` }], + }, + ]; + const result = truncateGenAiMessages(messages); + const truncLen = + 20_000 - + 2 - + JSON.stringify({ + role: 'user', + content: [{ type: 'text', text: '' }], + }).length; + expect(result).toStrictEqual([ + { + role: 'user', + content: [{ type: 'text', text: `2 ${humongous}`.substring(0, truncLen) }], + }, + ]); + }); + + it('drops subsequent content array items that do not fit', () => { + const messages = [ + { + role: 'assistant', + content: [ + { type: 'text', text: `1 ${big}` }, + { type: 'image_url', url: 'https://example.com/img.png' }, + { type: 'text', text: `2 ${big}` }, + { type: 'text', text: `3 ${big}` }, + { type: 'text', text: `4 ${giant}` }, + { type: 'text', text: `5 ${giant}` }, + ], + }, + ]; + const result = truncateGenAiMessages(messages); + expect(result).toStrictEqual([ + { + role: 'assistant', + content: [ + { type: 'text', text: `1 ${big}` }, + { type: 'image_url', url: 'https://example.com/img.png' }, + { type: 'text', text: `2 ${big}` }, + { type: 'text', text: `3 ${big}` }, + ], + }, + ]); + }); + + it('drops content array message if overhead is too large', () => { + const messages = [ + { + some_other_field: humongous, + content: [{ type: 'text', text: 'hello' }], + }, + ]; + expect(truncateGenAiMessages(messages)).toStrictEqual([]); + }); }); }); From 83cabf3667915b1dc8f2cf224358c466cf38adbb Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 24 Mar 2026 17:27:18 +0100 Subject: [PATCH 37/43] fix(core): Preserve .withResponse() on Anthropic instrumentation (#19935) The Anthropic SDK lets you call `.withResponse()` and `.asResponse()` on the result of `client.messages.create()` (see https://platform.claude.com/docs/en/api/sdks/typescript#accessing-raw-response-data-e-g-headers). Our instrumentation was breaking this because the SDK returns a custom `APIPromise` (a Promise subclass with extra methods) and our wrapping stripped those methods away. We had the exact same problem with OpenAI (#19073) and fixed it using a Proxy that routes `.then/.catch/.finally` to the instrumented promise (preserving spans) while forwarding SDK-specific methods like `.withResponse()` to the original. This PR refactors that solution into a shared utility and applies it to the Anthropic integration for both non-streaming and streaming paths. Closes https://github.com/getsentry/sentry-javascript/issues/19912 --- .../anthropic/scenario-with-response.mjs | 199 ++++++++++++++++++ .../suites/tracing/anthropic/test.ts | 31 +++ packages/core/src/tracing/ai/utils.ts | 80 +++++++ .../core/src/tracing/anthropic-ai/index.ts | 55 +++-- packages/core/src/tracing/openai/index.ts | 81 +------ .../core/test/lib/tracing/ai/utils.test.ts | 83 +++++++- 6 files changed, 436 insertions(+), 93 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-with-response.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-with-response.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-with-response.mjs new file mode 100644 index 000000000000..c7e0a4f5a9ce --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-with-response.mjs @@ -0,0 +1,199 @@ +import * as Sentry from '@sentry/node'; +import express from 'express'; +import Anthropic from '@anthropic-ai/sdk'; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json()); + + app.post('/anthropic/v1/messages', (req, res) => { + const model = req.body.model; + + res.set('request-id', 'req_withresponse_test'); + + if (req.body.stream) { + res.set('content-type', 'text/event-stream'); + res.flushHeaders(); + + const events = [ + `event: message_start\ndata: ${JSON.stringify({ type: 'message_start', message: { id: 'msg_stream_withresponse', type: 'message', role: 'assistant', model, content: [], usage: { input_tokens: 10 } } })}\n\n`, + `event: content_block_start\ndata: ${JSON.stringify({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } })}\n\n`, + `event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Streaming with response!' } })}\n\n`, + `event: content_block_stop\ndata: ${JSON.stringify({ type: 'content_block_stop', index: 0 })}\n\n`, + `event: message_delta\ndata: ${JSON.stringify({ type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 5 } })}\n\n`, + `event: message_stop\ndata: ${JSON.stringify({ type: 'message_stop' })}\n\n`, + ]; + + let i = 0; + const interval = setInterval(() => { + if (i < events.length) { + res.write(events[i]); + i++; + } else { + clearInterval(interval); + res.end(); + } + }, 10); + return; + } + + res.send({ + id: 'msg_withresponse', + type: 'message', + model: model, + role: 'assistant', + content: [ + { + type: 'text', + text: 'Testing .withResponse() method!', + }, + ], + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockAnthropicServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new Anthropic({ + apiKey: 'mock-api-key', + baseURL: `http://localhost:${server.address().port}/anthropic`, + }); + + // Test 1: Verify .withResponse() method is preserved and works correctly + const result = client.messages.create({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'Test withResponse' }], + }); + + // Verify .withResponse() method exists and can be called + if (typeof result.withResponse !== 'function') { + throw new Error('.withResponse() method does not exist'); + } + + // Call .withResponse() and verify structure + const withResponseResult = await result.withResponse(); + + // Verify expected properties are present + if (!withResponseResult.data) { + throw new Error('.withResponse() did not return data'); + } + if (!withResponseResult.response) { + throw new Error('.withResponse() did not return response'); + } + if (withResponseResult.request_id === undefined) { + throw new Error('.withResponse() did not return request_id'); + } + + // Verify returned data structure matches expected Anthropic response + const { data } = withResponseResult; + if (data.id !== 'msg_withresponse') { + throw new Error(`Expected data.id to be 'msg_withresponse', got '${data.id}'`); + } + if (data.model !== 'claude-3-haiku-20240307') { + throw new Error(`Expected data.model to be 'claude-3-haiku-20240307', got '${data.model}'`); + } + if (data.content[0].text !== 'Testing .withResponse() method!') { + throw new Error( + `Expected data.content[0].text to be 'Testing .withResponse() method!', got '${data.content[0].text}'`, + ); + } + + // Verify response is a Response object with correct headers + if (!(withResponseResult.response instanceof Response)) { + throw new Error('response is not a Response object'); + } + if (withResponseResult.response.headers.get('request-id') !== 'req_withresponse_test') { + throw new Error( + `Expected request-id header 'req_withresponse_test', got '${withResponseResult.response.headers.get('request-id')}'`, + ); + } + + // Verify request_id matches the header + if (withResponseResult.request_id !== 'req_withresponse_test') { + throw new Error(`Expected request_id 'req_withresponse_test', got '${withResponseResult.request_id}'`); + } + + // Test 2: Verify .asResponse() method works + const result2 = client.messages.create({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'Test asResponse' }], + }); + + // Verify .asResponse() method exists and can be called + if (typeof result2.asResponse !== 'function') { + throw new Error('.asResponse() method does not exist'); + } + + // Call .asResponse() and verify it returns raw Response + const rawResponse = await result2.asResponse(); + + // Verify response is a Response object with correct headers + if (!(rawResponse instanceof Response)) { + throw new Error('.asResponse() did not return a Response object'); + } + + // Verify response has correct status + if (rawResponse.status !== 200) { + throw new Error(`Expected status 200, got ${rawResponse.status}`); + } + + // Verify response headers + if (rawResponse.headers.get('request-id') !== 'req_withresponse_test') { + throw new Error( + `Expected request-id header 'req_withresponse_test', got '${rawResponse.headers.get('request-id')}'`, + ); + } + + // Test 3: Verify .withResponse() works with streaming (stream: true) + const streamResult = client.messages.create({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'Test stream withResponse' }], + stream: true, + }); + + if (typeof streamResult.withResponse !== 'function') { + throw new Error('.withResponse() method does not exist on streaming result'); + } + + const streamWithResponse = await streamResult.withResponse(); + + if (!streamWithResponse.data) { + throw new Error('streaming .withResponse() did not return data'); + } + if (!streamWithResponse.response) { + throw new Error('streaming .withResponse() did not return response'); + } + if (streamWithResponse.request_id === undefined) { + throw new Error('streaming .withResponse() did not return request_id'); + } + + if (!(streamWithResponse.response instanceof Response)) { + throw new Error('streaming response is not a Response object'); + } + if (streamWithResponse.response.headers.get('request-id') !== 'req_withresponse_test') { + throw new Error( + `Expected request-id header 'req_withresponse_test', got '${streamWithResponse.response.headers.get('request-id')}'`, + ); + } + + // Consume the stream to allow span to complete + for await (const _ of streamWithResponse.data) { + void _; + } + }); + + // Wait for the stream event handler to finish + await Sentry.flush(2000); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts index 2c72ec7daadd..fb520e5e09b6 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -359,6 +359,37 @@ describe('Anthropic integration', () => { }); }); + createEsmAndCjsTests(__dirname, 'scenario-with-response.mjs', 'instrument.mjs', (createRunner, test) => { + const chatSpan = (responseId: string) => + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'chat', + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: 'claude-3-haiku-20240307', + [GEN_AI_RESPONSE_ID_ATTRIBUTE]: responseId, + }), + description: 'chat claude-3-haiku-20240307', + op: 'gen_ai.chat', + status: 'ok', + }); + + test('preserves .withResponse() and .asResponse() for non-streaming and streaming', async () => { + await createRunner() + .ignore('event') + .expect({ + transaction: { + transaction: 'main', + spans: expect.arrayContaining([ + chatSpan('msg_withresponse'), + chatSpan('msg_withresponse'), + chatSpan('msg_stream_withresponse'), + ]), + }, + }) + .start() + .completed(); + }); + }); + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { test('creates anthropic related spans with sendDefaultPii: false', async () => { await createRunner() diff --git a/packages/core/src/tracing/ai/utils.ts b/packages/core/src/tracing/ai/utils.ts index 46602a54553a..38e4d831db3f 100644 --- a/packages/core/src/tracing/ai/utils.ts +++ b/packages/core/src/tracing/ai/utils.ts @@ -1,8 +1,10 @@ /** * Shared utils for AI integrations (OpenAI, Anthropic, Verce.AI, etc.) */ +import { captureException } from '../../exports'; import { getClient } from '../../currentScopes'; import type { Span } from '../../types-hoist/span'; +import { isThenable } from '../../utils/is'; import { GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, @@ -172,3 +174,81 @@ export function extractSystemInstructions(messages: unknown[] | unknown): { return { systemInstructions, filteredMessages }; } + +/** + * Creates a wrapped version of .withResponse() that replaces the data field + * with the instrumented result while preserving metadata (response, request_id). + */ +async function createWithResponseWrapper( + originalWithResponse: Promise, + instrumentedPromise: Promise, + mechanismType: string, +): Promise { + // Attach catch handler to originalWithResponse immediately to prevent unhandled rejection + // If instrumentedPromise rejects first, we still need this handled + const safeOriginalWithResponse = originalWithResponse.catch(error => { + captureException(error, { + mechanism: { + handled: false, + type: mechanismType, + }, + }); + throw error; + }); + + const instrumentedResult = await instrumentedPromise; + const originalWrapper = await safeOriginalWithResponse; + + // Combine instrumented result with original metadata + if (originalWrapper && typeof originalWrapper === 'object' && 'data' in originalWrapper) { + return { + ...originalWrapper, + data: instrumentedResult, + }; + } + return instrumentedResult; +} + +/** + * Wraps a promise-like object to preserve additional methods (like .withResponse()) + * that AI SDK clients (OpenAI, Anthropic) attach to their APIPromise return values. + * + * Standard Promise methods (.then, .catch, .finally) are routed to the instrumented + * promise to preserve Sentry's span instrumentation, while custom SDK methods are + * forwarded to the original promise to maintain the SDK's API surface. + */ +export function wrapPromiseWithMethods( + originalPromiseLike: Promise, + instrumentedPromise: Promise, + mechanismType: string, +): Promise { + // If the original result is not thenable, return the instrumented promise + if (!isThenable(originalPromiseLike)) { + return instrumentedPromise; + } + + // Create a proxy that forwards Promise methods to instrumentedPromise + // and preserves additional methods from the original result + return new Proxy(originalPromiseLike, { + get(target: object, prop: string | symbol): unknown { + // For standard Promise methods (.then, .catch, .finally, Symbol.toStringTag), + // use instrumentedPromise to preserve Sentry instrumentation. + // For custom methods (like .withResponse()), use the original target. + const useInstrumentedPromise = prop in Promise.prototype || prop === Symbol.toStringTag; + const source = useInstrumentedPromise ? instrumentedPromise : target; + + const value = Reflect.get(source, prop) as unknown; + + // Special handling for .withResponse() to preserve instrumentation + // .withResponse() returns { data: T, response: Response, request_id: string } + if (prop === 'withResponse' && typeof value === 'function') { + return function wrappedWithResponse(this: unknown): unknown { + const originalWithResponse = (value as (...args: unknown[]) => unknown).call(target); + return createWithResponseWrapper(originalWithResponse, instrumentedPromise, mechanismType); + }; + } + + return typeof value === 'function' ? value.bind(source) : value; + }, + }) as Promise; +} diff --git a/packages/core/src/tracing/anthropic-ai/index.ts b/packages/core/src/tracing/anthropic-ai/index.ts index f677fe5eb90f..693ecbd23ff8 100644 --- a/packages/core/src/tracing/anthropic-ai/index.ts +++ b/packages/core/src/tracing/anthropic-ai/index.ts @@ -3,7 +3,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import { SPAN_STATUS_ERROR } from '../../tracing'; import { startSpan, startSpanManual } from '../../tracing/trace'; import type { Span, SpanAttributeValue } from '../../types-hoist/span'; -import { handleCallbackErrors } from '../../utils/handleCallbackErrors'; import { ANTHROPIC_AI_RESPONSE_TIMESTAMP_ATTRIBUTE, GEN_AI_OPERATION_NAME_ATTRIBUTE, @@ -28,6 +27,7 @@ import { getSpanOperation, resolveAIRecordingOptions, setTokenUsageAttributes, + wrapPromiseWithMethods, } from '../ai/utils'; import { instrumentAsyncIterableStream, instrumentMessageStream } from './streaming'; import type { @@ -218,21 +218,30 @@ function handleStreamingRequest( // messages.stream() always returns a sync MessageStream, even with stream: true param if (isStreamRequested && !isStreamingMethod) { - return startSpanManual(spanConfig, async span => { - try { - if (options.recordInputs && params) { - addPrivateRequestAttributes(span, params); - } - const result = await originalMethod.apply(context, args); - return instrumentAsyncIterableStream( - result as AsyncIterable, - span, - options.recordOutputs ?? false, - ) as unknown as R; - } catch (error) { - return handleStreamingError(error, span, methodPath); + let originalResult!: Promise; + + const instrumentedPromise = startSpanManual(spanConfig, (span: Span) => { + originalResult = originalMethod.apply(context, args) as Promise; + + if (options.recordInputs && params) { + addPrivateRequestAttributes(span, params); } + + return (async () => { + try { + const result = await originalResult; + return instrumentAsyncIterableStream( + result as AsyncIterable, + span, + options.recordOutputs ?? false, + ) as unknown as R; + } catch (error) { + return handleStreamingError(error, span, methodPath); + } + })(); }); + + return wrapPromiseWithMethods(originalResult, instrumentedPromise, 'auto.ai.anthropic'); } else { return startSpanManual(spanConfig, span => { try { @@ -285,19 +294,26 @@ function instrumentMethod( ); } - return startSpan( + let originalResult!: Promise; + + const instrumentedPromise = startSpan( { name: `${operationName} ${model}`, op: getSpanOperation(methodPath), attributes: requestAttributes as Record, }, span => { + originalResult = target.apply(context, args) as Promise; + if (options.recordInputs && params) { addPrivateRequestAttributes(span, params); } - return handleCallbackErrors( - () => target.apply(context, args), + return originalResult.then( + result => { + addResponseAttributes(span, result as AnthropicAiResponse, options.recordOutputs); + return result; + }, error => { captureException(error, { mechanism: { @@ -308,12 +324,13 @@ function instrumentMethod( }, }, }); + throw error; }, - () => {}, - result => addResponseAttributes(span, result as AnthropicAiResponse, options.recordOutputs), ); }, ); + + return wrapPromiseWithMethods(originalResult, instrumentedPromise, 'auto.ai.anthropic'); }, }) as (...args: T) => R | Promise; } diff --git a/packages/core/src/tracing/openai/index.ts b/packages/core/src/tracing/openai/index.ts index 484128810b01..afcf853f2a9d 100644 --- a/packages/core/src/tracing/openai/index.ts +++ b/packages/core/src/tracing/openai/index.ts @@ -5,7 +5,6 @@ import { SPAN_STATUS_ERROR } from '../../tracing'; import { startSpan, startSpanManual } from '../../tracing/trace'; import type { Span, SpanAttributeValue } from '../../types-hoist/span'; import { debug } from '../../utils/debug-logger'; -import { isThenable } from '../../utils/is'; import { GEN_AI_EMBEDDINGS_INPUT_ATTRIBUTE, GEN_AI_INPUT_MESSAGES_ATTRIBUTE, @@ -18,7 +17,12 @@ import { GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE, OPENAI_OPERATIONS, } from '../ai/gen-ai-attributes'; -import { extractSystemInstructions, getTruncatedJsonString, resolveAIRecordingOptions } from '../ai/utils'; +import { + extractSystemInstructions, + getTruncatedJsonString, + resolveAIRecordingOptions, + wrapPromiseWithMethods, +} from '../ai/utils'; import { instrumentStream } from './streaming'; import type { ChatCompletionChunk, @@ -172,75 +176,6 @@ function addRequestAttributes(span: Span, params: Record, opera } } -/** - * Creates a wrapped version of .withResponse() that replaces the data field - * with the instrumented result while preserving metadata (response, request_id). - */ -async function createWithResponseWrapper( - originalWithResponse: Promise, - instrumentedPromise: Promise, -): Promise { - // Attach catch handler to originalWithResponse immediately to prevent unhandled rejection - // If instrumentedPromise rejects first, we still need this handled - const safeOriginalWithResponse = originalWithResponse.catch(error => { - captureException(error, { - mechanism: { - handled: false, - type: 'auto.ai.openai', - }, - }); - throw error; - }); - - const instrumentedResult = await instrumentedPromise; - const originalWrapper = await safeOriginalWithResponse; - - // Combine instrumented result with original metadata - if (originalWrapper && typeof originalWrapper === 'object' && 'data' in originalWrapper) { - return { - ...originalWrapper, - data: instrumentedResult, - }; - } - return instrumentedResult; -} - -/** - * Wraps a promise-like object to preserve additional methods (like .withResponse()) - */ -function wrapPromiseWithMethods(originalPromiseLike: Promise, instrumentedPromise: Promise): Promise { - // If the original result is not thenable, return the instrumented promise - // Should not happen with current OpenAI SDK instrumented methods, but just in case. - if (!isThenable(originalPromiseLike)) { - return instrumentedPromise; - } - - // Create a proxy that forwards Promise methods to instrumentedPromise - // and preserves additional methods from the original result - return new Proxy(originalPromiseLike, { - get(target: object, prop: string | symbol): unknown { - // For standard Promise methods (.then, .catch, .finally, Symbol.toStringTag), - // use instrumentedPromise to preserve Sentry instrumentation. - // For custom methods (like .withResponse()), use the original target. - const useInstrumentedPromise = prop in Promise.prototype || prop === Symbol.toStringTag; - const source = useInstrumentedPromise ? instrumentedPromise : target; - - const value = Reflect.get(source, prop) as unknown; - - // Special handling for .withResponse() to preserve instrumentation - // .withResponse() returns { data: T, response: Response, request_id: string } - if (prop === 'withResponse' && typeof value === 'function') { - return function wrappedWithResponse(this: unknown): unknown { - const originalWithResponse = (value as (...args: unknown[]) => unknown).call(target); - return createWithResponseWrapper(originalWithResponse, instrumentedPromise); - }; - } - - return typeof value === 'function' ? value.bind(source) : value; - }, - }) as Promise; -} - /** * Instrument a method with Sentry spans * Following Sentry AI Agents Manual Instrumentation conventions @@ -300,7 +235,7 @@ function instrumentMethod( })(); }); - return wrapPromiseWithMethods(originalResult, instrumentedPromise); + return wrapPromiseWithMethods(originalResult, instrumentedPromise, 'auto.ai.openai'); } // Non-streaming @@ -332,7 +267,7 @@ function instrumentMethod( ); }); - return wrapPromiseWithMethods(originalResult, instrumentedPromise); + return wrapPromiseWithMethods(originalResult, instrumentedPromise, 'auto.ai.openai'); }; } diff --git a/packages/core/test/lib/tracing/ai/utils.test.ts b/packages/core/test/lib/tracing/ai/utils.test.ts index 28f98c846619..43f16e70c0f2 100644 --- a/packages/core/test/lib/tracing/ai/utils.test.ts +++ b/packages/core/test/lib/tracing/ai/utils.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { getCurrentScope, getGlobalScope, getIsolationScope, setCurrentClient } from '../../../../src'; -import { resolveAIRecordingOptions } from '../../../../src/tracing/ai/utils'; +import { resolveAIRecordingOptions, wrapPromiseWithMethods } from '../../../../src/tracing/ai/utils'; import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; describe('resolveAIRecordingOptions', () => { @@ -38,3 +38,84 @@ describe('resolveAIRecordingOptions', () => { expect(resolveAIRecordingOptions({ recordInputs: false })).toEqual({ recordInputs: false, recordOutputs: true }); }); }); + +describe('wrapPromiseWithMethods', () => { + /** + * Creates a mock APIPromise that mimics the behavior of OpenAI/Anthropic SDK APIPromise. + * The returned object is a thenable with extra methods like .withResponse() and .asResponse(). + */ + function createMockAPIPromise(value: T, metadata: { response: object; request_id: string }) { + const resolvedPromise = Promise.resolve(value); + const apiPromise = Object.assign(resolvedPromise, { + withResponse: () => + Promise.resolve({ + data: value, + response: metadata.response, + request_id: metadata.request_id, + }), + asResponse: () => Promise.resolve(metadata.response), + }); + return apiPromise; + } + + it('routes .then() to instrumentedPromise', async () => { + const original = createMockAPIPromise('original-data', { + response: { status: 200 }, + request_id: 'req_123', + }); + const instrumented = Promise.resolve('instrumented-data'); + const wrapped = wrapPromiseWithMethods(original, instrumented, 'auto.ai.test'); + + const result = await wrapped; + expect(result).toBe('instrumented-data'); + }); + + it('routes .withResponse() to original and swaps data with instrumented result', async () => { + const original = createMockAPIPromise('original-data', { + response: { status: 200 }, + request_id: 'req_123', + }); + const instrumented = Promise.resolve('instrumented-data'); + const wrapped = wrapPromiseWithMethods(original, instrumented, 'auto.ai.test'); + + const withResponseResult = await (wrapped as typeof original).withResponse(); + expect(withResponseResult).toEqual({ + data: 'instrumented-data', + response: { status: 200 }, + request_id: 'req_123', + }); + }); + + it('routes .asResponse() to original', async () => { + const mockResponse = { status: 200, headers: new Map() }; + const original = createMockAPIPromise('original-data', { + response: mockResponse, + request_id: 'req_123', + }); + const instrumented = Promise.resolve('instrumented-data'); + const wrapped = wrapPromiseWithMethods(original, instrumented, 'auto.ai.test'); + + const response = await (wrapped as typeof original).asResponse(); + expect(response).toBe(mockResponse); + }); + + it('returns instrumentedPromise when original is not thenable', async () => { + const instrumented = Promise.resolve('instrumented-data'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const wrapped = wrapPromiseWithMethods(null as any, instrumented, 'auto.ai.test'); + + const result = await wrapped; + expect(result).toBe('instrumented-data'); + }); + + it('propagates errors from instrumentedPromise', async () => { + const original = createMockAPIPromise('original-data', { + response: { status: 200 }, + request_id: 'req_123', + }); + const instrumented = Promise.reject(new Error('instrumented-error')); + const wrapped = wrapPromiseWithMethods(original, instrumented, 'auto.ai.test'); + + await expect(wrapped).rejects.toThrow('instrumented-error'); + }); +}); From c9812ae0b91bd9e352012d67b0cc53bf818f8fb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Tue, 24 Mar 2026 20:07:39 +0100 Subject: [PATCH 38/43] test(cloudflare): Enable multi-worker tests for CF integration tests (#19938) This adds tests for multi worker in integration tests by adding a `wrangler-sub-worker.jsonc` into it. Everything else is then according the official [Service Binding docs](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/). So in the `worker-service-bindings` folder there is now a test which connects "Worker A" with "Worker B". This is enabled by having "Worker B" running as `wrangler-sub-worker.jsonc` and "Worker A" is referencing to "Worker B" inside the normal "wrangler.jsonc". Inside "Worker A" you can then use `env.ANOTHER_WORKER` to have an RPC between two workers. This will be important for the tests for #16898 --- .../cloudflare-integration-tests/runner.ts | 63 ++++++++++++++----- .../index-sub-worker.ts | 19 ++++++ .../tracing/worker-service-binding/index.ts | 20 ++++++ .../tracing/worker-service-binding/test.ts | 45 +++++++++++++ .../wrangler-sub-worker.jsonc | 9 +++ .../worker-service-binding/wrangler.jsonc | 15 +++++ 6 files changed, 157 insertions(+), 14 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index-sub-worker.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/wrangler-sub-worker.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/runner.ts b/dev-packages/cloudflare-integration-tests/runner.ts index a9fb96b59505..b0b439eb122a 100644 --- a/dev-packages/cloudflare-integration-tests/runner.ts +++ b/dev-packages/cloudflare-integration-tests/runner.ts @@ -105,6 +105,7 @@ export function createRunner(...paths: string[]) { let envelopeCount = 0; const { resolve: setWorkerPort, promise: workerPortPromise } = deferredPromise(); let child: ReturnType | undefined; + let childSubWorker: ReturnType | undefined; /** Called after each expect callback to check if we're complete */ function expectCallbackCalled(): void { @@ -168,7 +169,7 @@ export function createRunner(...paths: string[]) { } createBasicSentryServer(newEnvelope) - .then(([mockServerPort, mockServerClose]) => { + .then(async ([mockServerPort, mockServerClose]) => { if (mockServerClose) { CLEANUP_STEPS.add(() => { mockServerClose(); @@ -181,6 +182,49 @@ export function createRunner(...paths: string[]) { ? ['inherit', 'inherit', 'inherit', 'ipc'] : ['ignore', 'ignore', 'ignore', 'ipc']; + const onChildError = (e: Error) => { + // eslint-disable-next-line no-console + console.error('Error starting child process:', e); + reject(e); + }; + + function onChildMessage(message: string, onReady?: (port: number) => void): void { + const msg = JSON.parse(message) as { event: string; port?: number }; + if (msg.event === 'DEV_SERVER_READY' && typeof msg.port === 'number') { + if (process.env.DEBUG) log('worker ready on port', msg.port); + onReady?.(msg.port); + } + } + + if (existsSync(join(testPath, 'wrangler-sub-worker.jsonc'))) { + childSubWorker = spawn( + 'wrangler', + [ + 'dev', + '--config', + join(testPath, 'wrangler-sub-worker.jsonc'), + '--show-interactive-dev-session', + 'false', + '--var', + `SENTRY_DSN:http://public@localhost:${mockServerPort}/1337`, + '--port', + '0', + '--inspector-port', + '0', + ], + { stdio, signal }, + ); + + // Wait for the sub-worker to be ready before starting the main worker + await new Promise((resolveSubWorker, rejectSubWorker) => { + childSubWorker!.on('message', (msg: string) => onChildMessage(msg, () => resolveSubWorker())); + childSubWorker!.on('error', rejectSubWorker); + childSubWorker!.on('exit', code => { + rejectSubWorker(new Error(`Sub-worker exited with code ${code}`)); + }); + }); + } + child = spawn( 'wrangler', [ @@ -199,21 +243,12 @@ export function createRunner(...paths: string[]) { CLEANUP_STEPS.add(() => { child?.kill(); + childSubWorker?.kill(); }); - child.on('error', e => { - // eslint-disable-next-line no-console - console.error('Error starting child process:', e); - reject(e); - }); - - child.on('message', (message: string) => { - const msg = JSON.parse(message) as { event: string; port?: number }; - if (msg.event === 'DEV_SERVER_READY' && typeof msg.port === 'number') { - setWorkerPort(msg.port); - if (process.env.DEBUG) log('worker ready on port', msg.port); - } - }); + childSubWorker?.on('error', onChildError); + child.on('error', onChildError); + child.on('message', (msg: string) => onChildMessage(msg, setWorkerPort)); }) .catch(e => reject(e)); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index-sub-worker.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index-sub-worker.ts new file mode 100644 index 000000000000..06c79931b880 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index-sub-worker.ts @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +const myWorker = { + async fetch(request: Request) { + return new Response('Hello from another worker!'); + }, +}; + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + myWorker, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index.ts new file mode 100644 index 000000000000..dc178759f51d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index.ts @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; + ANOTHER_WORKER: Fetcher; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(request, env) { + const response = await env.ANOTHER_WORKER.fetch(new Request('http://fake-host/hello')); + const text = await response.text(); + return new Response(text); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/test.ts new file mode 100644 index 000000000000..fd64c0d31d27 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/test.ts @@ -0,0 +1,45 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../runner'; + +it('adds a trace to a worker via service binding', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /', + }), + ); + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /hello', + }), + ); + }) + .unordered() + .start(signal); + await runner.makeRequest('get', '/'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/wrangler-sub-worker.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/wrangler-sub-worker.jsonc new file mode 100644 index 000000000000..30bd95322560 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/wrangler-sub-worker.jsonc @@ -0,0 +1,9 @@ +{ + "name": "cloudflare-service-binding-sub-worker", + "main": "index-sub-worker.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "vars": { + "SENTRY_DSN": "https://932e620ee3921c3b4a61c72558ad88ce@o447951.ingest.us.sentry.io/4509553159831552", + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/wrangler.jsonc new file mode 100644 index 000000000000..69bc9f6e6e99 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/wrangler.jsonc @@ -0,0 +1,15 @@ +{ + "name": "cloudflare-worker-service-binding", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "vars": { + "SENTRY_DSN": "https://932e620ee3921c3b4a61c72558ad88ce@o447951.ingest.us.sentry.io/4509553159831552", + }, + "services": [ + { + "binding": "ANOTHER_WORKER", + "service": "cloudflare-service-binding-sub-worker", + }, + ], +} From 18a624ee52acfbe16a133b9a04652668a23e6b95 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 24 Mar 2026 15:28:24 -0400 Subject: [PATCH 39/43] feat(elysia): Elysia SDK (#19509) ### Key Decisions: - Built on `@sentry/bun` since Elysia is Bun-first. - We uses Elysia's first-party OTel plugin for lifecycle instrumentation instead of rolling our own. - We had to filter out `bunServerIntegration` to avoid competing root spans, as Elysia creates its own. - Error handling: Captures `5xx` and <= `299`, skips `3xx/4xx`. Aligned with Fastify. Overridable via `shouldHandleError`. - Client hooks registered once via module-level guard, safe to call withElysia() multiple times. - Elysia produces empty child spans for arrow function handlers. We collect their IDs in `spanEnd` (still empty at that point) and strip them in `beforeSendEvent`. Unless the user provides a named function, we will strip them, check the trace below as an example of a named function `logRequest` vs the stripped event handlers in other life cycle hooks. CleanShot 2026-03-05 at 15 19 34@2x CleanShot 2026-03-05 at 15 02 12@2x TODOs: - [x] Plugin API to address registration order. - [x] Figure out a way to drop the root span or parameterize it. - [x] Transform into an SDK Closes #18956 --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .craft.yml | 9 + .github/ISSUE_TEMPLATE/bug.yml | 1 + .github/workflows/build.yml | 2 +- .size-limit.js | 26 +- README.md | 1 + .../test-applications/elysia-bun/.gitignore | 1 + .../test-applications/elysia-bun/.npmrc | 2 + .../test-applications/elysia-bun/package.json | 26 ++ .../elysia-bun/playwright.config.mjs | 7 + .../test-applications/elysia-bun/src/app.ts | 142 +++++++ .../elysia-bun/start-event-proxy.mjs | 6 + .../elysia-bun/tests/errors.test.ts | 109 +++++ .../elysia-bun/tests/isolation.test.ts | 30 ++ .../elysia-bun/tests/propagation.test.ts | 113 ++++++ .../elysia-bun/tests/transactions.test.ts | 211 ++++++++++ .../elysia-bun/tsconfig.json | 11 + .../test-applications/elysia-node/.gitignore | 1 + .../test-applications/elysia-node/.npmrc | 2 + .../elysia-node/package.json | 27 ++ .../elysia-node/playwright.config.mjs | 7 + .../test-applications/elysia-node/src/app.ts | 143 +++++++ .../elysia-node/start-event-proxy.mjs | 6 + .../elysia-node/tests/errors.test.ts | 109 +++++ .../elysia-node/tests/isolation.test.ts | 30 ++ .../elysia-node/tests/propagation.test.ts | 113 ++++++ .../elysia-node/tests/transactions.test.ts | 211 ++++++++++ .../elysia-node/tsconfig.json | 11 + .../scripts/consistentExports.ts | 3 + .../e2e-tests/verdaccio-config/config.yaml | 6 + package.json | 7 +- packages/bun/src/sdk.ts | 2 +- packages/elysia/.oxlintrc.json | 4 + packages/elysia/LICENSE | 21 + packages/elysia/README.md | 38 ++ packages/elysia/package.json | 86 ++++ packages/elysia/rollup.npm.config.mjs | 3 + packages/elysia/src/clientHooks.ts | 68 ++++ packages/elysia/src/index.ts | 195 +++++++++ packages/elysia/src/sdk.ts | 60 +++ packages/elysia/src/types.ts | 6 + packages/elysia/src/withElysia.ts | 139 +++++++ packages/elysia/test/sdk.test.ts | 124 ++++++ packages/elysia/test/withElysia.test.ts | 184 +++++++++ packages/elysia/tsconfig.json | 10 + packages/elysia/tsconfig.test.json | 12 + packages/elysia/tsconfig.types.json | 10 + yarn.lock | 375 +++++++++++++++--- 47 files changed, 2642 insertions(+), 68 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/elysia-bun/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/elysia-bun/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/elysia-bun/package.json create mode 100644 dev-packages/e2e-tests/test-applications/elysia-bun/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/elysia-bun/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/elysia-bun/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/elysia-bun/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/elysia-bun/tests/isolation.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/elysia-bun/tests/propagation.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/elysia-bun/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/elysia-bun/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/elysia-node/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/elysia-node/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/elysia-node/package.json create mode 100644 dev-packages/e2e-tests/test-applications/elysia-node/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/elysia-node/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/elysia-node/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/elysia-node/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/elysia-node/tests/isolation.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/elysia-node/tests/propagation.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/elysia-node/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/elysia-node/tsconfig.json create mode 100644 packages/elysia/.oxlintrc.json create mode 100644 packages/elysia/LICENSE create mode 100644 packages/elysia/README.md create mode 100644 packages/elysia/package.json create mode 100644 packages/elysia/rollup.npm.config.mjs create mode 100644 packages/elysia/src/clientHooks.ts create mode 100644 packages/elysia/src/index.ts create mode 100644 packages/elysia/src/sdk.ts create mode 100644 packages/elysia/src/types.ts create mode 100644 packages/elysia/src/withElysia.ts create mode 100644 packages/elysia/test/sdk.test.ts create mode 100644 packages/elysia/test/withElysia.test.ts create mode 100644 packages/elysia/tsconfig.json create mode 100644 packages/elysia/tsconfig.test.json create mode 100644 packages/elysia/tsconfig.types.json diff --git a/.craft.yml b/.craft.yml index 57b3224d654e..7e2ee3217533 100644 --- a/.craft.yml +++ b/.craft.yml @@ -95,6 +95,9 @@ targets: - name: npm id: '@sentry/bun' includeNames: /^sentry-bun-\d.*\.tgz$/ + - name: npm + id: '@sentry/elysia' + includeNames: /^sentry-elysia-\d.*\.tgz$/ - name: npm id: '@sentry/hono' includeNames: /^sentry-hono-\d.*\.tgz$/ @@ -247,3 +250,9 @@ targets: packageUrl: 'https://www.npmjs.com/package/@sentry/effect' mainDocsUrl: 'https://docs.sentry.io/platforms/javascript/guides/effect/' onlyIfPresent: /^sentry-effect-\d.*\.tgz$/ + 'npm:@sentry/elysia': + name: 'Sentry Elysia SDK' + sdkName: 'sentry.javascript.elysia' + packageUrl: 'https://www.npmjs.com/package/@sentry/elysia' + mainDocsUrl: 'https://docs.sentry.io/platforms/javascript/guides/elysia/' + onlyIfPresent: /^sentry-elysia-\d.*\.tgz$/ diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 305e975b48fd..47edbfeed264 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -46,6 +46,7 @@ body: - '@sentry/cloudflare - hono' - '@sentry/deno' - '@sentry/effect' + - '@sentry/elysia' - '@sentry/ember' - '@sentry/gatsby' - '@sentry/google-cloud-serverless' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 69523f544f2f..dea6b4802dc5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -961,7 +961,7 @@ jobs: with: node-version-file: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' - name: Set up Bun - if: contains(fromJSON('["node-exports-test-app","nextjs-16-bun"]'), matrix.test-application) + if: contains(fromJSON('["node-exports-test-app","nextjs-16-bun", "elysia-bun"]'), matrix.test-application) uses: oven-sh/setup-bun@v2 - name: Set up AWS SAM if: matrix.test-application == 'aws-serverless' diff --git a/.size-limit.js b/.size-limit.js index 3e0902c0a57c..5141775bf367 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -184,7 +184,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing)', path: createCDNPath('bundle.tracing.min.js'), gzip: true, - limit: '44 KB', + limit: '45 KB', }, { name: 'CDN Bundle (incl. Logs, Metrics)', @@ -196,37 +196,37 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Logs, Metrics)', path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: true, - limit: '45 KB', + limit: '46 KB', }, { name: 'CDN Bundle (incl. Replay, Logs, Metrics)', path: createCDNPath('bundle.replay.logs.metrics.min.js'), gzip: true, - limit: '69 KB', + limit: '70 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay)', path: createCDNPath('bundle.tracing.replay.min.js'), gzip: true, - limit: '81 KB', + limit: '82 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics)', path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: true, - limit: '82 KB', + limit: '83 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback)', path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: true, - limit: '86 KB', + limit: '88 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)', path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: true, - limit: '87 KB', + limit: '88 KB', }, // browser CDN bundles (non-gzipped) { @@ -241,7 +241,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '129 KB', + limit: '133 KB', }, { name: 'CDN Bundle (incl. Logs, Metrics) - uncompressed', @@ -255,28 +255,28 @@ module.exports = [ path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '132 KB', + limit: '136 KB', }, { name: 'CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed', path: createCDNPath('bundle.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '210 KB', + limit: '212 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed', path: createCDNPath('bundle.tracing.replay.min.js'), gzip: false, brotli: false, - limit: '246 KB', + limit: '250 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed', path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '250 KB', + limit: '253 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed', @@ -290,7 +290,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '264 KB', + limit: '266 KB', }, // Next.js SDK (ESM) { diff --git a/README.md b/README.md index 5ac7fefc3b81..841a6380b5e2 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ package. Please refer to the README and instructions of those SDKs for more deta for native crashes - [`@sentry/effect`](https://github.com/getsentry/sentry-javascript/tree/master/packages/effect): SDK for Effect (Alpha) - [`@sentry/bun`](https://github.com/getsentry/sentry-javascript/tree/master/packages/bun): SDK for Bun +- [`@sentry/elysia`](https://github.com/getsentry/sentry-javascript/tree/master/packages/elysia): SDK for Elysia - [`@sentry/deno`](https://github.com/getsentry/sentry-javascript/tree/master/packages/deno): SDK for Deno - [`@sentry/cloudflare`](https://github.com/getsentry/sentry-javascript/tree/master/packages/cloudflare): SDK for Cloudflare diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/.gitignore b/dev-packages/e2e-tests/test-applications/elysia-bun/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/.npmrc b/dev-packages/e2e-tests/test-applications/elysia-bun/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/package.json b/dev-packages/e2e-tests/test-applications/elysia-bun/package.json new file mode 100644 index 000000000000..dd0123725147 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/package.json @@ -0,0 +1,26 @@ +{ + "name": "elysia-bun-app", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "bun src/app.ts", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "dependencies": { + "@elysiajs/opentelemetry": "^1.4.0", + "@sentry/elysia": "latest || *", + "elysia": "^1.4.0" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "bun-types": "^1.2.9" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/elysia-bun/playwright.config.mjs new file mode 100644 index 000000000000..44d3b834833d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: 'bun src/app.ts', +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/src/app.ts b/dev-packages/e2e-tests/test-applications/elysia-bun/src/app.ts new file mode 100644 index 000000000000..db6a5fff8bd1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/src/app.ts @@ -0,0 +1,142 @@ +import * as Sentry from '@sentry/elysia'; +import { Elysia } from 'elysia'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, + tracePropagationTargets: ['http://localhost:3030', '/external-allowed'], +}); + +const app = Sentry.withElysia(new Elysia()); + +// Simple success route +app.get('/test-success', () => ({ version: 'v1' })); + +// Parameterized route +app.get('/test-param/:param', ({ params }) => ({ paramWas: params.param })); + +// Multiple params +app.get('/test-multi-param/:param1/:param2', ({ params }) => ({ + param1: params.param1, + param2: params.param2, +})); + +// Route that throws an error (will be caught by onError) +app.get('/test-exception/:id', ({ params }) => { + throw new Error(`This is an exception with id ${params.id}`); +}); + +// Route with a custom span +app.get('/test-transaction', () => { + Sentry.startSpan({ name: 'test-span' }, () => { + Sentry.startSpan({ name: 'child-span' }, () => {}); + }); + return { status: 'ok' }; +}); + +// Route with specific middleware via .guard or .use +app.group('/with-middleware', app => + app + .onBeforeHandle(() => { + // This is a route-specific middleware + }) + .get('/test', () => ({ middleware: true })), +); + +// Error with specific status code +app.post('/test-post-error', () => { + throw new Error('Post error'); +}); + +// Route that returns a non-500 error +app.get('/test-4xx', ({ set }) => { + set.status = 400; + return { error: 'Bad Request' }; +}); + +// Error that reaches the error handler with status still set to 200 (unusual, should still be captured) +app.get('/test-error-with-200-status', ({ set }) => { + set.status = 200; + throw new Error('Error with 200 status'); +}); + +// POST route that echoes body +app.post('/test-post', ({ body }) => ({ status: 'ok', body })); + +// Route that returns inbound headers (for propagation tests) +app.get('/test-inbound-headers/:id', ({ params, request }) => { + const headers = Object.fromEntries(request.headers.entries()); + return { headers, id: params.id }; +}); + +// Outgoing fetch propagation +app.get('/test-outgoing-fetch/:id', async ({ params }) => { + const id = params.id; + const response = await fetch(`http://localhost:3030/test-inbound-headers/${id}`); + const data = await response.json(); + return data; +}); + +// Outgoing fetch to external (allowed by tracePropagationTargets) +app.get('/test-outgoing-fetch-external-allowed', async () => { + const response = await fetch(`http://localhost:3040/external-allowed`); + const data = await response.json(); + return data; +}); + +// Outgoing fetch to external (disallowed by tracePropagationTargets) +app.get('/test-outgoing-fetch-external-disallowed', async () => { + const response = await fetch(`http://localhost:3040/external-disallowed`); + const data = await response.json(); + return data; +}); + +// Route that throws a string (not an Error object) +app.get('/test-string-error', () => { + // eslint-disable-next-line no-throw-literal + throw 'String error message'; +}); + +// Route for concurrent isolation tests — returns scope data in response +app.get('/test-isolation/:userId', async ({ params }) => { + Sentry.setUser({ id: params.userId }); + Sentry.setTag('user_id', params.userId); + + // Simulate async work to increase overlap between concurrent requests + await new Promise(resolve => setTimeout(resolve, 200)); + + return { + userId: params.userId, + isolationScopeUserId: Sentry.getIsolationScope().getUser()?.id, + isolationScopeTag: Sentry.getIsolationScope().getScopeData().tags?.user_id, + }; +}); + +// Flush route for waiting on events +app.get('/flush', async () => { + await Sentry.flush(); + return { ok: true }; +}); + +app.listen(3030, () => { + console.log('Elysia app listening on port 3030'); +}); + +// Second app for external propagation tests +const app2 = new Elysia(); + +app2.get('/external-allowed', ({ request }) => { + const headers = Object.fromEntries(request.headers.entries()); + return { headers, route: '/external-allowed' }; +}); + +app2.get('/external-disallowed', ({ request }) => { + const headers = Object.fromEntries(request.headers.entries()); + return { headers, route: '/external-disallowed' }; +}); + +app2.listen(3040, () => { + console.log('External app listening on port 3040'); +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/elysia-bun/start-event-proxy.mjs new file mode 100644 index 000000000000..7519e8a3f650 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'elysia-bun', +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/elysia-bun/tests/errors.test.ts new file mode 100644 index 000000000000..8bd544e6f9c2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/tests/errors.test.ts @@ -0,0 +1,109 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Captures an error thrown in a route handler', async ({ baseURL, request }) => { + const errorEventPromise = waitForError('elysia-bun', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + await request.get(`${baseURL}/test-exception/123`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + const exception = errorEvent.exception?.values?.[0]; + expect(exception?.value).toBe('This is an exception with id 123'); + expect(exception?.mechanism).toEqual({ + type: 'auto.http.elysia.on_error', + handled: false, + }); + + expect(errorEvent.transaction).toEqual('GET /test-exception/:id'); + + expect(errorEvent.contexts?.trace).toEqual( + expect.objectContaining({ + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }), + ); +}); + +test('Error event includes request metadata', async ({ baseURL, request }) => { + const errorEventPromise = waitForError('elysia-bun', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 456'; + }); + + await request.get(`${baseURL}/test-exception/456`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.request).toEqual( + expect.objectContaining({ + method: 'GET', + url: expect.stringContaining('/test-exception/456'), + headers: expect.any(Object), + }), + ); +}); + +test('Does not capture errors for 4xx responses', async ({ baseURL, request }) => { + const transactionPromise = waitForTransaction('elysia-bun', transactionEvent => { + return transactionEvent?.transaction === 'GET /test-4xx'; + }); + + const response = await request.get(`${baseURL}/test-4xx`); + // Wait for the transaction to ensure the request was processed + await transactionPromise; + + expect(response.status()).toBe(400); +}); + +test('Captures errors even when status is <= 299 in error handler', async ({ baseURL, request }) => { + const errorEventPromise = waitForError('elysia-bun', event => { + return !event.type && event.exception?.values?.[0]?.value === 'Error with 200 status'; + }); + + await request.get(`${baseURL}/test-error-with-200-status`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values?.[0]?.value).toBe('Error with 200 status'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + type: 'auto.http.elysia.on_error', + handled: false, + }); +}); + +test('Captures POST route errors', async ({ baseURL, request }) => { + const errorEventPromise = waitForError('elysia-bun', event => { + return !event.type && event.exception?.values?.[0]?.value === 'Post error'; + }); + + await request.post(`${baseURL}/test-post-error`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values?.[0]?.value).toBe('Post error'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + type: 'auto.http.elysia.on_error', + handled: false, + }); +}); + +test('Captures thrown string errors', async ({ baseURL, request }) => { + const errorEventPromise = waitForError('elysia-bun', event => { + return !event.type && event.exception?.values?.[0]?.value === 'String error message'; + }); + + await request.get(`${baseURL}/test-string-error`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values?.[0]?.value).toBe('String error message'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ + type: 'auto.http.elysia.on_error', + handled: false, + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/tests/isolation.test.ts b/dev-packages/e2e-tests/test-applications/elysia-bun/tests/isolation.test.ts new file mode 100644 index 000000000000..3bdc1cc2e99d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/tests/isolation.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from '@playwright/test'; + +// The Elysia integration currently does not fork isolation scopes per request, +// so `setUser`/`setTag` on the isolation scope leaks between concurrent requests. +// This test documents the expected behavior once per-request isolation is implemented. +test.fixme('Concurrent requests have isolated scope data', async ({ baseURL }) => { + // Fire 3 concurrent requests with different user IDs + const [response1, response2, response3] = await Promise.all([ + fetch(`${baseURL}/test-isolation/user-1`), + fetch(`${baseURL}/test-isolation/user-2`), + fetch(`${baseURL}/test-isolation/user-3`), + ]); + + const data1 = await response1.json(); + const data2 = await response2.json(); + const data3 = await response3.json(); + + // Each response should have its own user ID — no leaking between requests + expect(data1.userId).toBe('user-1'); + expect(data1.isolationScopeUserId).toBe('user-1'); + expect(data1.isolationScopeTag).toBe('user-1'); + + expect(data2.userId).toBe('user-2'); + expect(data2.isolationScopeUserId).toBe('user-2'); + expect(data2.isolationScopeTag).toBe('user-2'); + + expect(data3.userId).toBe('user-3'); + expect(data3.isolationScopeUserId).toBe('user-3'); + expect(data3.isolationScopeTag).toBe('user-3'); +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/elysia-bun/tests/propagation.test.ts new file mode 100644 index 000000000000..c07dea3c9dc6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/tests/propagation.test.ts @@ -0,0 +1,113 @@ +import { randomUUID } from 'node:crypto'; +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Includes sentry-trace and baggage in response headers', async ({ baseURL }) => { + const response = await fetch(`${baseURL}/test-success`); + + const sentryTrace = response.headers.get('sentry-trace'); + const baggage = response.headers.get('baggage'); + + expect(sentryTrace).toMatch(/[a-f0-9]{32}-[a-f0-9]{16}-[01]/); + expect(baggage).toContain('sentry-environment=qa'); + expect(baggage).toContain('sentry-trace_id='); +}); + +// Bun's native fetch does not emit undici diagnostics channels, +// so the nativeNodeFetchIntegration cannot inject sentry-trace/baggage headers. +// These tests document the desired behavior and will pass once Bun adds support +// for undici diagnostics channels or an alternative propagation mechanism is added. + +test.fixme('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { + const id = randomUUID(); + + const inboundTransactionPromise = waitForTransaction('elysia-bun', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-inbound-headers/:id' + ); + }); + + const outboundTransactionPromise = waitForTransaction('elysia-bun', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-outgoing-fetch/:id' + ); + }); + + const response = await fetch(`${baseURL}/test-outgoing-fetch/${id}`); + const data = await response.json(); + + const inboundTransaction = await inboundTransactionPromise; + const outboundTransaction = await outboundTransactionPromise; + + const traceId = outboundTransaction?.contexts?.trace?.trace_id; + expect(traceId).toEqual(expect.any(String)); + + // Verify sentry-trace header was propagated to the inbound request + const inboundHeaderSentryTrace = data.headers?.['sentry-trace']; + const inboundHeaderBaggage = data.headers?.['baggage']; + + expect(inboundHeaderSentryTrace).toMatch(new RegExp(`^${traceId}-[a-f0-9]{16}-1$`)); + expect(inboundHeaderBaggage).toBeDefined(); + + const baggage = (inboundHeaderBaggage || '').split(','); + expect(baggage).toEqual( + expect.arrayContaining([ + 'sentry-environment=qa', + `sentry-trace_id=${traceId}`, + expect.stringMatching(/sentry-public_key=/), + ]), + ); + + // Both transactions should share the same trace ID + expect(inboundTransaction.contexts?.trace?.trace_id).toBe(traceId); +}); + +test.fixme('Propagates trace for outgoing fetch to external allowed URL', async ({ baseURL }) => { + const inboundTransactionPromise = waitForTransaction('elysia-bun', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-outgoing-fetch-external-allowed' + ); + }); + + const response = await fetch(`${baseURL}/test-outgoing-fetch-external-allowed`); + const data = await response.json(); + + const inboundTransaction = await inboundTransactionPromise; + const traceId = inboundTransaction?.contexts?.trace?.trace_id; + + expect(traceId).toEqual(expect.any(String)); + + expect(data.route).toBe('/external-allowed'); + expect(data.headers?.['sentry-trace']).toMatch(/[a-f0-9]{32}-[a-f0-9]{16}-1/); + expect(data.headers?.baggage).toBeDefined(); + + const baggage = (data.headers.baggage || '').split(','); + expect(baggage).toEqual( + expect.arrayContaining([ + 'sentry-environment=qa', + `sentry-trace_id=${traceId}`, + expect.stringMatching(/sentry-public_key=/), + ]), + ); +}); + +test('Does not propagate outgoing fetch requests not covered by tracePropagationTargets', async ({ baseURL }) => { + const inboundTransactionPromise = waitForTransaction('elysia-bun', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-outgoing-fetch-external-disallowed' + ); + }); + + const response = await fetch(`${baseURL}/test-outgoing-fetch-external-disallowed`); + const data = await response.json(); + + await inboundTransactionPromise; + + expect(data.route).toBe('/external-disallowed'); + expect(data.headers?.['sentry-trace']).toBeUndefined(); + expect(data.headers?.baggage).toBeUndefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/elysia-bun/tests/transactions.test.ts new file mode 100644 index 000000000000..e0f05a1be483 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/tests/transactions.test.ts @@ -0,0 +1,211 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends a transaction for a successful route', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /test-success' + ); + }); + + await request.get(`${baseURL}/test-success`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'GET /test-success', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); + + expect(transactionEvent.contexts?.trace).toEqual( + expect.objectContaining({ + op: 'http.server', + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }), + ); +}); + +test('Sends a transaction with parameterized route name', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-param/:param' + ); + }); + + await request.get(`${baseURL}/test-param/123`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.transaction).toBe('GET /test-param/:param'); + expect(transactionEvent.transaction_info?.source).toBe('route'); +}); + +test('Sends a transaction with multiple parameterized segments', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-multi-param/:param1/:param2' + ); + }); + + await request.get(`${baseURL}/test-multi-param/foo/bar`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.transaction).toBe('GET /test-multi-param/:param1/:param2'); + expect(transactionEvent.transaction_info?.source).toBe('route'); +}); + +test('Sends a transaction for an errored route', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-exception/:id' + ); + }); + + await request.get(`${baseURL}/test-exception/777`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.transaction).toBe('GET /test-exception/:id'); + expect(transactionEvent.contexts?.trace?.status).toBe('internal_error'); +}); + +test('Includes manually started spans with parent-child relationship', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); + + await request.get(`${baseURL}/test-transaction`); + + const transactionEvent = await transactionEventPromise; + const spans = transactionEvent.spans || []; + + const testSpan = spans.find(span => span.description === 'test-span'); + const childSpan = spans.find(span => span.description === 'child-span'); + + expect(testSpan).toEqual( + expect.objectContaining({ + description: 'test-span', + origin: 'manual', + }), + ); + + expect(childSpan).toEqual( + expect.objectContaining({ + description: 'child-span', + origin: 'manual', + parent_span_id: testSpan?.span_id, + }), + ); +}); + +test('Creates lifecycle spans for Elysia hooks', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /test-success' + ); + }); + + await request.get(`${baseURL}/test-success`); + + const transactionEvent = await transactionEventPromise; + const spans = transactionEvent.spans || []; + + // Elysia should produce lifecycle spans enriched with sentry attributes + const elysiaSpans = spans.filter(span => span.origin === 'auto.http.otel.elysia'); + expect(elysiaSpans.length).toBeGreaterThan(0); + + // The Handle span should be present as a request handler + expect(spans).toContainEqual( + expect.objectContaining({ + description: 'Handle', + op: 'request_handler.elysia', + origin: 'auto.http.otel.elysia', + }), + ); +}); + +test('Filters out empty anonymous Elysia spans but keeps all other spans', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /test-success' + ); + }); + + await request.get(`${baseURL}/test-success`); + + const transactionEvent = await transactionEventPromise; + const spans = transactionEvent.spans || []; + + // Elysia produces empty anonymous spans for arrow function handlers that show up as . + // These should be filtered out by our beforeSendEvent hook. + const unknownSpans = spans.filter(span => span.description === ''); + expect(unknownSpans).toHaveLength(0); + + // But named Elysia lifecycle spans should still be present + expect(spans.filter(span => span.origin === 'auto.http.otel.elysia').length).toBeGreaterThan(0); +}); + +test('Creates lifecycle spans for route-specific middleware', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /with-middleware/test' + ); + }); + + await request.get(`${baseURL}/with-middleware/test`); + + const transactionEvent = await transactionEventPromise; + const spans = transactionEvent.spans || []; + + // BeforeHandle span should be present from the route-specific middleware + expect(spans).toContainEqual( + expect.objectContaining({ + description: 'BeforeHandle', + op: 'middleware.elysia', + origin: 'auto.http.otel.elysia', + }), + ); +}); + +test('Captures request metadata for POST requests', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'POST /test-post' + ); + }); + + const response = await request.post(`${baseURL}/test-post`, { + data: { foo: 'bar', other: 1 }, + headers: { 'Content-Type': 'application/json' }, + }); + const resBody = await response.json(); + + expect(resBody).toEqual({ status: 'ok', body: { foo: 'bar', other: 1 } }); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.request).toEqual( + expect.objectContaining({ + method: 'POST', + url: expect.stringContaining('/test-post'), + headers: expect.objectContaining({ + 'content-type': 'application/json', + }), + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/tsconfig.json b/dev-packages/e2e-tests/test-applications/elysia-bun/tsconfig.json new file mode 100644 index 000000000000..869427e44d4d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["bun-types"], + "esModuleInterop": true, + "lib": ["es2020"], + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/.gitignore b/dev-packages/e2e-tests/test-applications/elysia-node/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-node/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/.npmrc b/dev-packages/e2e-tests/test-applications/elysia-node/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-node/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/package.json b/dev-packages/e2e-tests/test-applications/elysia-node/package.json new file mode 100644 index 000000000000..920c1fb019ce --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-node/package.json @@ -0,0 +1,27 @@ +{ + "name": "elysia-node-app", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node src/app.ts", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "dependencies": { + "@elysiajs/opentelemetry": "^1.4.10", + "@sentry/elysia": "latest || *", + "elysia": "latest", + "@elysiajs/node": "^1.4.5" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json", + "node": "24.11.0" + } +} diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/elysia-node/playwright.config.mjs new file mode 100644 index 000000000000..7b08eab8ba80 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-node/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: 'node src/app.ts', +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/src/app.ts b/dev-packages/e2e-tests/test-applications/elysia-node/src/app.ts new file mode 100644 index 000000000000..375ca9a29c6d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-node/src/app.ts @@ -0,0 +1,143 @@ +import * as Sentry from '@sentry/elysia'; +import { Elysia } from 'elysia'; +import { node } from '@elysiajs/node'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, + tracePropagationTargets: ['http://localhost:3030', '/external-allowed'], +}); + +const app = Sentry.withElysia(new Elysia({ adapter: node() })); + +// Simple success route +app.get('/test-success', () => ({ version: 'v1' })); + +// Parameterized route +app.get('/test-param/:param', ({ params }) => ({ paramWas: params.param })); + +// Multiple params +app.get('/test-multi-param/:param1/:param2', ({ params }) => ({ + param1: params.param1, + param2: params.param2, +})); + +// Route that throws an error (will be caught by onError) +app.get('/test-exception/:id', ({ params }) => { + throw new Error(`This is an exception with id ${params.id}`); +}); + +// Route with a custom span +app.get('/test-transaction', () => { + Sentry.startSpan({ name: 'test-span' }, () => { + Sentry.startSpan({ name: 'child-span' }, () => {}); + }); + return { status: 'ok' }; +}); + +// Route with specific middleware via .guard or .use +app.group('/with-middleware', app => + app + .onBeforeHandle(() => { + // This is a route-specific middleware + }) + .get('/test', () => ({ middleware: true })), +); + +// Error with specific status code +app.post('/test-post-error', () => { + throw new Error('Post error'); +}); + +// Route that returns a non-500 error +app.get('/test-4xx', ({ set }) => { + set.status = 400; + return { error: 'Bad Request' }; +}); + +// Error that reaches the error handler with status still set to 200 (unusual, should still be captured) +app.get('/test-error-with-200-status', ({ set }) => { + set.status = 200; + throw new Error('Error with 200 status'); +}); + +// POST route that echoes body +app.post('/test-post', ({ body }) => ({ status: 'ok', body })); + +// Route that returns inbound headers (for propagation tests) +app.get('/test-inbound-headers/:id', ({ params, request }) => { + const headers = Object.fromEntries(request.headers.entries()); + return { headers, id: params.id }; +}); + +// Outgoing fetch propagation +app.get('/test-outgoing-fetch/:id', async ({ params }) => { + const id = params.id; + const response = await fetch(`http://localhost:3030/test-inbound-headers/${id}`); + const data = await response.json(); + return data; +}); + +// Outgoing fetch to external (allowed by tracePropagationTargets) +app.get('/test-outgoing-fetch-external-allowed', async () => { + const response = await fetch(`http://localhost:3040/external-allowed`); + const data = await response.json(); + return data; +}); + +// Outgoing fetch to external (disallowed by tracePropagationTargets) +app.get('/test-outgoing-fetch-external-disallowed', async () => { + const response = await fetch(`http://localhost:3040/external-disallowed`); + const data = await response.json(); + return data; +}); + +// Route that throws a string (not an Error object) +app.get('/test-string-error', () => { + // eslint-disable-next-line no-throw-literal + throw 'String error message'; +}); + +// Route for concurrent isolation tests — returns scope data in response +app.get('/test-isolation/:userId', async ({ params }) => { + Sentry.setUser({ id: params.userId }); + Sentry.setTag('user_id', params.userId); + + // Simulate async work to increase overlap between concurrent requests + await new Promise(resolve => setTimeout(resolve, 200)); + + return { + userId: params.userId, + isolationScopeUserId: Sentry.getIsolationScope().getUser()?.id, + isolationScopeTag: Sentry.getIsolationScope().getScopeData().tags?.user_id, + }; +}); + +// Flush route for waiting on events +app.get('/flush', async () => { + await Sentry.flush(); + return { ok: true }; +}); + +app.listen(3030, () => { + console.log('Elysia app listening on port 3030'); +}); + +// Second app for external propagation tests +const app2 = new Elysia({ adapter: node() }); + +app2.get('/external-allowed', ({ request }) => { + const headers = Object.fromEntries(request.headers.entries()); + return { headers, route: '/external-allowed' }; +}); + +app2.get('/external-disallowed', ({ request }) => { + const headers = Object.fromEntries(request.headers.entries()); + return { headers, route: '/external-disallowed' }; +}); + +app2.listen(3040, () => { + console.log('External app listening on port 3040'); +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/elysia-node/start-event-proxy.mjs new file mode 100644 index 000000000000..85dfff25b5a6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-node/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'elysia-node', +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/elysia-node/tests/errors.test.ts new file mode 100644 index 000000000000..aac83b65e703 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-node/tests/errors.test.ts @@ -0,0 +1,109 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Captures an error thrown in a route handler', async ({ baseURL, request }) => { + const errorEventPromise = waitForError('elysia-node', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + await request.get(`${baseURL}/test-exception/123`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + const exception = errorEvent.exception?.values?.[0]; + expect(exception?.value).toBe('This is an exception with id 123'); + expect(exception?.mechanism).toEqual({ + type: 'auto.http.elysia.on_error', + handled: false, + }); + + expect(errorEvent.transaction).toEqual('GET /test-exception/:id'); + + expect(errorEvent.contexts?.trace).toEqual( + expect.objectContaining({ + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }), + ); +}); + +test('Error event includes request metadata', async ({ baseURL, request }) => { + const errorEventPromise = waitForError('elysia-node', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 456'; + }); + + await request.get(`${baseURL}/test-exception/456`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.request).toEqual( + expect.objectContaining({ + method: 'GET', + url: expect.stringContaining('/test-exception/456'), + headers: expect.any(Object), + }), + ); +}); + +test('Does not capture errors for 4xx responses', async ({ baseURL, request }) => { + const transactionPromise = waitForTransaction('elysia-node', transactionEvent => { + return transactionEvent?.transaction === 'GET /test-4xx'; + }); + + const response = await request.get(`${baseURL}/test-4xx`); + // Wait for the transaction to ensure the request was processed + await transactionPromise; + + expect(response.status()).toBe(400); +}); + +test('Captures errors even when status is <= 299 in error handler', async ({ baseURL, request }) => { + const errorEventPromise = waitForError('elysia-node', event => { + return !event.type && event.exception?.values?.[0]?.value === 'Error with 200 status'; + }); + + await request.get(`${baseURL}/test-error-with-200-status`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values?.[0]?.value).toBe('Error with 200 status'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + type: 'auto.http.elysia.on_error', + handled: false, + }); +}); + +test('Captures POST route errors', async ({ baseURL, request }) => { + const errorEventPromise = waitForError('elysia-node', event => { + return !event.type && event.exception?.values?.[0]?.value === 'Post error'; + }); + + await request.post(`${baseURL}/test-post-error`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values?.[0]?.value).toBe('Post error'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + type: 'auto.http.elysia.on_error', + handled: false, + }); +}); + +test('Captures thrown string errors', async ({ baseURL, request }) => { + const errorEventPromise = waitForError('elysia-node', event => { + return !event.type && event.exception?.values?.[0]?.value === 'String error message'; + }); + + await request.get(`${baseURL}/test-string-error`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values?.[0]?.value).toBe('String error message'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ + type: 'auto.http.elysia.on_error', + handled: false, + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/tests/isolation.test.ts b/dev-packages/e2e-tests/test-applications/elysia-node/tests/isolation.test.ts new file mode 100644 index 000000000000..3bdc1cc2e99d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-node/tests/isolation.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from '@playwright/test'; + +// The Elysia integration currently does not fork isolation scopes per request, +// so `setUser`/`setTag` on the isolation scope leaks between concurrent requests. +// This test documents the expected behavior once per-request isolation is implemented. +test.fixme('Concurrent requests have isolated scope data', async ({ baseURL }) => { + // Fire 3 concurrent requests with different user IDs + const [response1, response2, response3] = await Promise.all([ + fetch(`${baseURL}/test-isolation/user-1`), + fetch(`${baseURL}/test-isolation/user-2`), + fetch(`${baseURL}/test-isolation/user-3`), + ]); + + const data1 = await response1.json(); + const data2 = await response2.json(); + const data3 = await response3.json(); + + // Each response should have its own user ID — no leaking between requests + expect(data1.userId).toBe('user-1'); + expect(data1.isolationScopeUserId).toBe('user-1'); + expect(data1.isolationScopeTag).toBe('user-1'); + + expect(data2.userId).toBe('user-2'); + expect(data2.isolationScopeUserId).toBe('user-2'); + expect(data2.isolationScopeTag).toBe('user-2'); + + expect(data3.userId).toBe('user-3'); + expect(data3.isolationScopeUserId).toBe('user-3'); + expect(data3.isolationScopeTag).toBe('user-3'); +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/elysia-node/tests/propagation.test.ts new file mode 100644 index 000000000000..14bdbdf7ca6b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-node/tests/propagation.test.ts @@ -0,0 +1,113 @@ +import { randomUUID } from 'node:crypto'; +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Includes sentry-trace and baggage in response headers', async ({ baseURL }) => { + const response = await fetch(`${baseURL}/test-success`); + + const sentryTrace = response.headers.get('sentry-trace'); + const baggage = response.headers.get('baggage'); + + expect(sentryTrace).toMatch(/[a-f0-9]{32}-[a-f0-9]{16}-[01]/); + expect(baggage).toContain('sentry-environment=qa'); + expect(baggage).toContain('sentry-trace_id='); +}); + +// Bun's native fetch does not emit undici diagnostics channels, +// so the nativeNodeFetchIntegration cannot inject sentry-trace/baggage headers. +// These tests document the desired behavior and will pass once Bun adds support +// for undici diagnostics channels or an alternative propagation mechanism is added. + +test.fixme('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { + const id = randomUUID(); + + const inboundTransactionPromise = waitForTransaction('elysia-node', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-inbound-headers/:id' + ); + }); + + const outboundTransactionPromise = waitForTransaction('elysia-node', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-outgoing-fetch/:id' + ); + }); + + const response = await fetch(`${baseURL}/test-outgoing-fetch/${id}`); + const data = await response.json(); + + const inboundTransaction = await inboundTransactionPromise; + const outboundTransaction = await outboundTransactionPromise; + + const traceId = outboundTransaction?.contexts?.trace?.trace_id; + expect(traceId).toEqual(expect.any(String)); + + // Verify sentry-trace header was propagated to the inbound request + const inboundHeaderSentryTrace = data.headers?.['sentry-trace']; + const inboundHeaderBaggage = data.headers?.['baggage']; + + expect(inboundHeaderSentryTrace).toMatch(new RegExp(`^${traceId}-[a-f0-9]{16}-1$`)); + expect(inboundHeaderBaggage).toBeDefined(); + + const baggage = (inboundHeaderBaggage || '').split(','); + expect(baggage).toEqual( + expect.arrayContaining([ + 'sentry-environment=qa', + `sentry-trace_id=${traceId}`, + expect.stringMatching(/sentry-public_key=/), + ]), + ); + + // Both transactions should share the same trace ID + expect(inboundTransaction.contexts?.trace?.trace_id).toBe(traceId); +}); + +test.fixme('Propagates trace for outgoing fetch to external allowed URL', async ({ baseURL }) => { + const inboundTransactionPromise = waitForTransaction('elysia-node', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-outgoing-fetch-external-allowed' + ); + }); + + const response = await fetch(`${baseURL}/test-outgoing-fetch-external-allowed`); + const data = await response.json(); + + const inboundTransaction = await inboundTransactionPromise; + const traceId = inboundTransaction?.contexts?.trace?.trace_id; + + expect(traceId).toEqual(expect.any(String)); + + expect(data.route).toBe('/external-allowed'); + expect(data.headers?.['sentry-trace']).toMatch(/[a-f0-9]{32}-[a-f0-9]{16}-1/); + expect(data.headers?.baggage).toBeDefined(); + + const baggage = (data.headers.baggage || '').split(','); + expect(baggage).toEqual( + expect.arrayContaining([ + 'sentry-environment=qa', + `sentry-trace_id=${traceId}`, + expect.stringMatching(/sentry-public_key=/), + ]), + ); +}); + +test('Does not propagate outgoing fetch requests not covered by tracePropagationTargets', async ({ baseURL }) => { + const inboundTransactionPromise = waitForTransaction('elysia-node', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-outgoing-fetch-external-disallowed' + ); + }); + + const response = await fetch(`${baseURL}/test-outgoing-fetch-external-disallowed`); + const data = await response.json(); + + await inboundTransactionPromise; + + expect(data.route).toBe('/external-disallowed'); + expect(data.headers?.['sentry-trace']).toBeUndefined(); + expect(data.headers?.baggage).toBeUndefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/elysia-node/tests/transactions.test.ts new file mode 100644 index 000000000000..69ee7c48acf3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-node/tests/transactions.test.ts @@ -0,0 +1,211 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends a transaction for a successful route', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-node', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /test-success' + ); + }); + + await request.get(`${baseURL}/test-success`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'GET /test-success', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); + + expect(transactionEvent.contexts?.trace).toEqual( + expect.objectContaining({ + op: 'http.server', + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }), + ); +}); + +test('Sends a transaction with parameterized route name', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-node', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-param/:param' + ); + }); + + await request.get(`${baseURL}/test-param/123`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.transaction).toBe('GET /test-param/:param'); + expect(transactionEvent.transaction_info?.source).toBe('route'); +}); + +test('Sends a transaction with multiple parameterized segments', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-node', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-multi-param/:param1/:param2' + ); + }); + + await request.get(`${baseURL}/test-multi-param/foo/bar`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.transaction).toBe('GET /test-multi-param/:param1/:param2'); + expect(transactionEvent.transaction_info?.source).toBe('route'); +}); + +test('Sends a transaction for an errored route', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-node', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-exception/:id' + ); + }); + + await request.get(`${baseURL}/test-exception/777`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.transaction).toBe('GET /test-exception/:id'); + expect(transactionEvent.contexts?.trace?.status).toBe('internal_error'); +}); + +test('Includes manually started spans with parent-child relationship', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-node', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); + + await request.get(`${baseURL}/test-transaction`); + + const transactionEvent = await transactionEventPromise; + const spans = transactionEvent.spans || []; + + const testSpan = spans.find(span => span.description === 'test-span'); + const childSpan = spans.find(span => span.description === 'child-span'); + + expect(testSpan).toEqual( + expect.objectContaining({ + description: 'test-span', + origin: 'manual', + }), + ); + + expect(childSpan).toEqual( + expect.objectContaining({ + description: 'child-span', + origin: 'manual', + parent_span_id: testSpan?.span_id, + }), + ); +}); + +test('Creates lifecycle spans for Elysia hooks', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-node', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /test-success' + ); + }); + + await request.get(`${baseURL}/test-success`); + + const transactionEvent = await transactionEventPromise; + const spans = transactionEvent.spans || []; + + // Elysia should produce lifecycle spans enriched with sentry attributes + const elysiaSpans = spans.filter(span => span.origin === 'auto.http.otel.elysia'); + expect(elysiaSpans.length).toBeGreaterThan(0); + + // The Handle span should be present as a request handler + expect(spans).toContainEqual( + expect.objectContaining({ + description: 'Handle', + op: 'request_handler.elysia', + origin: 'auto.http.otel.elysia', + }), + ); +}); + +test('Filters out empty anonymous Elysia spans but keeps all other spans', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-node', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /test-success' + ); + }); + + await request.get(`${baseURL}/test-success`); + + const transactionEvent = await transactionEventPromise; + const spans = transactionEvent.spans || []; + + // Elysia produces empty anonymous spans for arrow function handlers that show up as . + // These should be filtered out by our beforeSendEvent hook. + const unknownSpans = spans.filter(span => span.description === ''); + expect(unknownSpans).toHaveLength(0); + + // But named Elysia lifecycle spans should still be present + expect(spans.filter(span => span.origin === 'auto.http.otel.elysia').length).toBeGreaterThan(0); +}); + +test('Creates lifecycle spans for route-specific middleware', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-node', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /with-middleware/test' + ); + }); + + await request.get(`${baseURL}/with-middleware/test`); + + const transactionEvent = await transactionEventPromise; + const spans = transactionEvent.spans || []; + + // BeforeHandle span should be present from the route-specific middleware + expect(spans).toContainEqual( + expect.objectContaining({ + description: 'BeforeHandle', + op: 'middleware.elysia', + origin: 'auto.http.otel.elysia', + }), + ); +}); + +test('Captures request metadata for POST requests', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-node', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'POST /test-post' + ); + }); + + const response = await request.post(`${baseURL}/test-post`, { + data: { foo: 'bar', other: 1 }, + headers: { 'Content-Type': 'application/json' }, + }); + const resBody = await response.json(); + + expect(resBody).toEqual({ status: 'ok', body: { foo: 'bar', other: 1 } }); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.request).toEqual( + expect.objectContaining({ + method: 'POST', + url: expect.stringContaining('/test-post'), + headers: expect.objectContaining({ + 'content-type': 'application/json', + }), + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/tsconfig.json b/dev-packages/e2e-tests/test-applications/elysia-node/tsconfig.json new file mode 100644 index 000000000000..869427e44d4d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-node/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["bun-types"], + "esModuleInterop": true, + "lib": ["es2020"], + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index 17c6f714c499..f195206fb5b2 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -41,6 +41,7 @@ const DEPENDENTS: Dependent[] = [ ignoreExports: [ // Not needed for Astro 'setupFastifyErrorHandler', + 'withElysia', ], }, { @@ -75,6 +76,7 @@ const DEPENDENTS: Dependent[] = [ ignoreExports: [ // Not needed for Serverless 'setupFastifyErrorHandler', + 'withElysia', ], }, { @@ -84,6 +86,7 @@ const DEPENDENTS: Dependent[] = [ ignoreExports: [ // Not needed for Serverless 'setupFastifyErrorHandler', + 'withElysia', ], }, { diff --git a/dev-packages/e2e-tests/verdaccio-config/config.yaml b/dev-packages/e2e-tests/verdaccio-config/config.yaml index beb758aca018..9d726fdf772f 100644 --- a/dev-packages/e2e-tests/verdaccio-config/config.yaml +++ b/dev-packages/e2e-tests/verdaccio-config/config.yaml @@ -80,6 +80,12 @@ packages: unpublish: $all # proxy: npmjs # Don't proxy for E2E tests! + '@sentry/elysia': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + '@sentry/ember': access: $all publish: $all diff --git a/package.json b/package.json index 42edbcaf8879..547b6a065fdb 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "packages/deno", "packages/effect", "packages/ember", + "packages/elysia", "packages/eslint-config-sdk", "packages/eslint-plugin-sdk", "packages/feedback", @@ -157,7 +158,11 @@ "wide-align/string-width": "4.2.3", "cliui/wrap-ansi": "7.0.0", "sucrase": "getsentry/sucrase#es2020-polyfills", - "**/express/path-to-regexp": "0.1.12" + "**/express/path-to-regexp": "0.1.12", + "**/@opentelemetry/core": "2.6.0", + "**/@opentelemetry/resources": "2.6.0", + "**/@opentelemetry/sdk-trace-base": "2.6.0", + "**/@opentelemetry/instrumentation": "0.213.0" }, "version": "0.0.0", "name": "sentry-javascript" diff --git a/packages/bun/src/sdk.ts b/packages/bun/src/sdk.ts index 7b79fa9383d6..6f9c06fdf88b 100644 --- a/packages/bun/src/sdk.ts +++ b/packages/bun/src/sdk.ts @@ -105,7 +105,7 @@ export function init(userOptions: BunOptions = {}): NodeClient | undefined { const options = { ...userOptions, platform: 'javascript', - runtime: { name: 'bun', version: Bun.version }, + runtime: { name: 'bun', version: typeof Bun !== 'undefined' ? Bun.version : 'unknown' }, serverName: userOptions.serverName || global.process.env.SENTRY_NAME || os.hostname(), }; diff --git a/packages/elysia/.oxlintrc.json b/packages/elysia/.oxlintrc.json new file mode 100644 index 000000000000..d38bbcf5c769 --- /dev/null +++ b/packages/elysia/.oxlintrc.json @@ -0,0 +1,4 @@ +{ + "$schema": "../../node_modules/oxlint/configuration_schema.json", + "extends": ["../../.oxlintrc.json"] +} diff --git a/packages/elysia/LICENSE b/packages/elysia/LICENSE new file mode 100644 index 000000000000..0ecae617386e --- /dev/null +++ b/packages/elysia/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Functional Software, Inc. dba Sentry + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/elysia/README.md b/packages/elysia/README.md new file mode 100644 index 000000000000..7565ad83ff40 --- /dev/null +++ b/packages/elysia/README.md @@ -0,0 +1,38 @@ +

+ + Sentry + +

+ +> NOTICE: This package is in alpha state and may be subject to breaking changes. + +# Official Sentry SDK for Elysia + +[![npm version](https://img.shields.io/npm/v/@sentry/elysia.svg)](https://www.npmjs.com/package/@sentry/elysia) +[![npm dm](https://img.shields.io/npm/dm/@sentry/elysia.svg)](https://www.npmjs.com/package/@sentry/elysia) +[![npm dt](https://img.shields.io/npm/dt/@sentry/elysia.svg)](https://www.npmjs.com/package/@sentry/elysia) + +> **Alpha**: This SDK is in alpha stage and may have breaking changes in future releases. + +## Usage + +```javascript +import * as Sentry from '@sentry/elysia'; +import { Elysia } from 'elysia'; + +Sentry.init({ + dsn: '__DSN__', + tracesSampleRate: 1.0, +}); + +const app = Sentry.withElysia(new Elysia()) + .get('/', () => 'Hello World') + .listen(3000); +``` + +## Links + + + +- [Sentry.io](https://sentry.io/?utm_source=github&utm_medium=npm_elysia) +- [Sentry Discord Server](https://discord.gg/Ww9hbqr) diff --git a/packages/elysia/package.json b/packages/elysia/package.json new file mode 100644 index 000000000000..18206f0c91e4 --- /dev/null +++ b/packages/elysia/package.json @@ -0,0 +1,86 @@ +{ + "name": "@sentry/elysia", + "version": "10.45.0", + "description": "Official Sentry SDK for Elysia", + "repository": "git://github.com/getsentry/sentry-javascript.git", + "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/elysia", + "author": "Sentry", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "files": [ + "/build" + ], + "main": "build/cjs/index.js", + "module": "build/esm/index.js", + "types": "build/types/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "import": { + "types": "./build/types/index.d.ts", + "default": "./build/esm/index.js" + }, + "require": { + "types": "./build/types/index.d.ts", + "default": "./build/cjs/index.js" + } + } + }, + "typesVersions": { + "<5.0": { + "build/types/index.d.ts": [ + "build/types-ts3.8/index.d.ts" + ] + } + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@sentry/bun": "10.45.0", + "@sentry/core": "10.45.0" + }, + "peerDependencies": { + "elysia": "^1.4.0", + "@elysiajs/opentelemetry": "^1.4.0" + }, + "devDependencies": { + "@elysiajs/opentelemetry": "^1.4.0", + "bun-types": "^1.2.9", + "elysia": "^1.4.0" + }, + "peerDependenciesMeta": { + "elysia": { + "optional": false + }, + "@elysiajs/opentelemetry": { + "optional": false + } + }, + "scripts": { + "build": "run-p build:transpile build:types", + "build:dev": "yarn build", + "build:transpile": "rollup -c rollup.npm.config.mjs", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/types build/types-ts3.8 --to ts3.8", + "build:watch": "run-p build:transpile:watch", + "build:dev:watch": "yarn build:watch", + "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch", + "build:tarball": "npm pack", + "circularDepCheck": "madge --circular src/index.ts", + "clean": "rimraf build coverage sentry-elysia-*.tgz", + "fix": "eslint . --format stylish --fix", + "lint": "eslint . --format stylish", + "lint:es-compatibility": "es-check es2022 ./build/cjs/*.js && es-check es2022 ./build/esm/*.js --module", + "test": "vitest run", + "test:watch": "vitest --watch", + "yalc:publish": "yalc publish --push --sig" + }, + "volta": { + "extends": "../../package.json" + }, + "sideEffects": false +} diff --git a/packages/elysia/rollup.npm.config.mjs b/packages/elysia/rollup.npm.config.mjs new file mode 100644 index 000000000000..6aa756423128 --- /dev/null +++ b/packages/elysia/rollup.npm.config.mjs @@ -0,0 +1,3 @@ +import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; + +export default makeNPMConfigVariants(makeBaseNPMConfig({})); diff --git a/packages/elysia/src/clientHooks.ts b/packages/elysia/src/clientHooks.ts new file mode 100644 index 000000000000..df77d2ca2612 --- /dev/null +++ b/packages/elysia/src/clientHooks.ts @@ -0,0 +1,68 @@ +import type { Client, Event, Span } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, spanToJSON } from '@sentry/core'; + +const ELYSIA_ORIGIN = 'auto.http.otel.elysia'; + +const ELYSIA_LIFECYCLE_OP_MAP: Record = { + Request: 'middleware.elysia', + Parse: 'middleware.elysia', + Transform: 'middleware.elysia', + BeforeHandle: 'middleware.elysia', + Handle: 'request_handler.elysia', + AfterHandle: 'middleware.elysia', + MapResponse: 'middleware.elysia', + AfterResponse: 'middleware.elysia', + Error: 'middleware.elysia', +}; + +/** + * Enrich Elysia lifecycle spans with semantic op and origin, + * and filter out empty anonymous child spans that Elysia produces. + */ +export function setupClientHooks(client: Client): void { + // Enrich Elysia lifecycle spans with semantic op and origin. + // We mutate the attributes directly because the span has already ended + // and `setAttribute()` is a no-op on ended OTel spans. + client.on('spanEnd', (span: Span) => { + const spanData = spanToJSON(span); + const op = ELYSIA_LIFECYCLE_OP_MAP[spanData.description || '']; + if (op && spanData.data) { + const attrs = spanData.data; + attrs[SEMANTIC_ATTRIBUTE_SENTRY_OP] = op; + attrs[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = ELYSIA_ORIGIN; + } + }); + + // Filter out empty child spans that Elysia produces for each function handler. + // Users usually use arrow functions so they show up as . + // We identify Elysia spans by checking if their parent is an Elysia lifecycle span + // (one we enriched with our origin), so we don't accidentally drop spans from other integrations. + client.on('beforeSendEvent', (event: Event) => { + if (event.type === 'transaction' && event.spans) { + const elysiaSpanIds = new Set(); + const filteredSpans: typeof event.spans = []; + + for (const span of event.spans) { + // Accumulate IDs of Elysia lifecycle spans + if (span.origin === ELYSIA_ORIGIN) { + elysiaSpanIds.add(span.span_id); + } + + // Decide whether to keep the span + if ( + (!span.description || span.description === '') && + span.parent_span_id && + elysiaSpanIds.has(span.parent_span_id) + ) { + continue; // filter out + } + filteredSpans.push(span); + } + + // Only update if we filtered something out (or could have) + if (elysiaSpanIds.size > 0) { + event.spans = filteredSpans; + } + } + }); +} diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts new file mode 100644 index 000000000000..8a5c5e622de1 --- /dev/null +++ b/packages/elysia/src/index.ts @@ -0,0 +1,195 @@ +// Re-export everything from @sentry/bun +export { + addEventProcessor, + addBreadcrumb, + addIntegration, + captureException, + captureEvent, + captureMessage, + captureCheckIn, + captureFeedback, + startSession, + captureSession, + endSession, + withMonitor, + createTransport, + getClient, + isInitialized, + isEnabled, + generateInstrumentOnce, + getCurrentScope, + getGlobalScope, + getIsolationScope, + getTraceData, + getTraceMetaTags, + setCurrentClient, + Scope, + SDK_VERSION, + setContext, + setConversationId, + setExtra, + setExtras, + setTag, + setTags, + setUser, + getSpanStatusFromHttpCode, + setHttpStatus, + withScope, + withIsolationScope, + makeNodeTransport, + NodeClient, + defaultStackParser, + lastEventId, + flush, + close, + getSentryRelease, + createGetModuleFromFilename, + createLangChainCallbackHandler, + httpHeadersToSpanAttributes, + winterCGHeadersToDict, + // eslint-disable-next-line deprecation/deprecation + anrIntegration, + // eslint-disable-next-line deprecation/deprecation + disableAnrDetectionForCallback, + consoleIntegration, + httpIntegration, + httpServerIntegration, + httpServerSpansIntegration, + nativeNodeFetchIntegration, + onUncaughtExceptionIntegration, + onUnhandledRejectionIntegration, + openAIIntegration, + langChainIntegration, + langGraphIntegration, + modulesIntegration, + contextLinesIntegration, + nodeContextIntegration, + localVariablesIntegration, + requestDataIntegration, + fsIntegration, + functionToStringIntegration, + // eslint-disable-next-line deprecation/deprecation + inboundFiltersIntegration, + eventFiltersIntegration, + linkedErrorsIntegration, + setMeasurement, + getActiveSpan, + startSpan, + startInactiveSpan, + startSpanManual, + startNewTrace, + suppressTracing, + withActiveSpan, + getRootSpan, + getSpanDescendants, + continueTrace, + getAutoPerformanceIntegrations, + cron, + parameterize, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + dataloaderIntegration, + expressIntegration, + expressErrorHandler, + setupExpressErrorHandler, + fastifyIntegration, + setupFastifyErrorHandler, + firebaseIntegration, + koaIntegration, + setupKoaErrorHandler, + connectIntegration, + setupConnectErrorHandler, + genericPoolIntegration, + graphqlIntegration, + knexIntegration, + kafkaIntegration, + lruMemoizerIntegration, + mongoIntegration, + mongooseIntegration, + mysqlIntegration, + mysql2Integration, + redisIntegration, + tediousIntegration, + postgresIntegration, + postgresJsIntegration, + prismaIntegration, + processSessionIntegration, + hapiIntegration, + setupHapiErrorHandler, + honoIntegration, + setupHonoErrorHandler, + spotlightIntegration, + initOpenTelemetry, + spanToJSON, + spanToTraceHeader, + spanToBaggageHeader, + trpcMiddleware, + updateSpanName, + supabaseIntegration, + instrumentSupabaseClient, + instrumentOpenAiClient, + instrumentAnthropicAiClient, + instrumentGoogleGenAIClient, + instrumentLangGraph, + instrumentStateGraphCompile, + zodErrorsIntegration, + profiler, + amqplibIntegration, + anthropicAIIntegration, + googleGenAIIntegration, + vercelAIIntegration, + logger, + consoleLoggingIntegration, + createConsolaReporter, + createSentryWinstonTransport, + wrapMcpServerWithSentry, + featureFlagsIntegration, + launchDarklyIntegration, + growthbookIntegration, + buildLaunchDarklyFlagUsedHandler, + openFeatureIntegration, + OpenFeatureIntegrationHook, + statsigIntegration, + unleashIntegration, + metrics, + bunServerIntegration, + makeFetchTransport, +} from '@sentry/bun'; + +export type { + Breadcrumb, + BreadcrumbHint, + PolymorphicRequest, + RequestEventData, + SdkInfo, + Event, + EventHint, + ErrorEvent, + Exception, + Session, + SeverityLevel, + Span, + StackFrame, + Stacktrace, + Thread, + User, + FeatureFlagsIntegration, + Metric, + ExclusiveEventHintOrCaptureContext, + CaptureContext, +} from '@sentry/core'; + +export { + captureConsoleIntegration, + dedupeIntegration, + extraErrorDataIntegration, + rewriteFramesIntegration, +} from '@sentry/core'; + +export type { ElysiaOptions } from './types'; + +// Elysia-specific exports +export { withElysia } from './withElysia'; +export { getDefaultIntegrations, init } from './sdk'; diff --git a/packages/elysia/src/sdk.ts b/packages/elysia/src/sdk.ts new file mode 100644 index 000000000000..f8d8dd9d8a35 --- /dev/null +++ b/packages/elysia/src/sdk.ts @@ -0,0 +1,60 @@ +import * as os from 'node:os'; +import { + bunServerIntegration, + getDefaultIntegrations as getBunDefaultIntegrations, + makeFetchTransport, +} from '@sentry/bun'; +import type { Integration, Options } from '@sentry/core'; +import { applySdkMetadata } from '@sentry/core'; +import { init as initNode, type NodeClient } from '@sentry/bun'; +import type { ElysiaOptions } from './types'; + +/** Get the default integrations for the Elysia SDK. */ +export function getDefaultIntegrations(_options: Options): Integration[] { + // Filter out bunServerIntegration + // Elysia already produces an HTTP server span, so we don't need Bun's competing root span. + return getBunDefaultIntegrations(_options).filter(i => i.name !== bunServerIntegration().name); +} + +/** + * Get the runtime name and version. + */ +function getRuntime(): { name: string; version: string } { + if (typeof Bun !== 'undefined') { + return { name: 'bun', version: Bun.version }; + } + + return { name: 'node', version: process.version }; +} + +/** + * Initializes the Sentry Elysia SDK. + * + * @example + * ```javascript + * import * as Sentry from '@sentry/elysia'; + * + * Sentry.init({ + * dsn: '__DSN__', + * tracesSampleRate: 1.0, + * }); + * ``` + */ +export function init(userOptions: ElysiaOptions = {}): NodeClient | undefined { + const options = { + ...userOptions, + platform: 'javascript', + runtime: getRuntime(), + serverName: userOptions.serverName || global.process.env.SENTRY_NAME || os.hostname(), + }; + + applySdkMetadata(userOptions, 'elysia', ['elysia', options.runtime.name]); + + options.transport = options.transport || makeFetchTransport; + + if (options.defaultIntegrations === undefined) { + options.defaultIntegrations = getDefaultIntegrations(options); + } + + return initNode(options); +} diff --git a/packages/elysia/src/types.ts b/packages/elysia/src/types.ts new file mode 100644 index 000000000000..78b4afd14e87 --- /dev/null +++ b/packages/elysia/src/types.ts @@ -0,0 +1,6 @@ +import type { BunOptions } from '@sentry/bun'; + +/** + * Configuration options for the Sentry Elysia SDK. + */ +export type ElysiaOptions = BunOptions; diff --git a/packages/elysia/src/withElysia.ts b/packages/elysia/src/withElysia.ts new file mode 100644 index 000000000000..9efeee39481a --- /dev/null +++ b/packages/elysia/src/withElysia.ts @@ -0,0 +1,139 @@ +import { opentelemetry } from '@elysiajs/opentelemetry'; +import { + captureException, + getActiveSpan, + getClient, + getIsolationScope, + getRootSpan, + getTraceData, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + updateSpanName, + winterCGRequestToRequestData, +} from '@sentry/core'; +import type { Elysia, ErrorContext } from 'elysia'; +import { setupClientHooks } from './clientHooks'; + +interface ElysiaHandlerOptions { + shouldHandleError?: (context: ErrorContext) => boolean; +} + +function isBun(): boolean { + return typeof Bun !== 'undefined'; +} + +let isClientHooksSetup = false; +const instrumentedApps = new WeakSet(); + +/** + * Updates the root span and isolation scope with the parameterized route name. + * Only needed on Node.js where the root span comes from HTTP instrumentation. + */ +function updateRouteTransactionName(method: string, route: string): void { + const transactionName = `${method} ${route}`; + + const activeSpan = getActiveSpan(); + if (activeSpan) { + const rootSpan = getRootSpan(activeSpan); + updateSpanName(rootSpan, transactionName); + rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + } + + getIsolationScope().setTransactionName(transactionName); +} + +function defaultShouldHandleError(context: ErrorContext): boolean { + const status = context.set.status; + if (status === undefined) { + return true; + } + const statusCode = parseInt(String(status), 10); + if (Number.isNaN(statusCode)) { + return true; + } + // Capture server errors (5xx) and unusual status codes (<= 299 in an error handler). + // 3xx and 4xx are not captured by default (client errors / redirects). + return statusCode >= 500 || statusCode <= 299; +} + +/** + * Integrate Sentry with an Elysia app for error handling, request context, + * and tracing. Returns the app instance for chaining. + * + * Should be called at the **start** of the chain before defining routes. + * + * @param app The Elysia instance + * @param options Configuration options + * @returns The same Elysia instance for chaining + * + * @example + * ```javascript + * import * as Sentry from '@sentry/elysia'; + * import { Elysia } from 'elysia'; + * + * Sentry.withElysia(new Elysia()) + * .get('/', () => 'Hello World') + * .listen(3000); + * ``` + */ +export function withElysia(app: T, options: ElysiaHandlerOptions = {}): T { + if (instrumentedApps.has(app)) { + return app; + } + instrumentedApps.add(app); + + // Register the opentelemetry plugin + // https://elysiajs.com/plugins/opentelemetry + app.use(opentelemetry()); + + if (!isClientHooksSetup) { + const client = getClient(); + if (client) { + isClientHooksSetup = true; + setupClientHooks(client); + } + } + + // Set SDK processing metadata for all requests + app.onRequest(context => { + getIsolationScope().setSDKProcessingMetadata({ + normalizedRequest: winterCGRequestToRequestData(context.request), + }); + }); + + // Propagate trace data to all response headers and update transaction name + app.onAfterHandle({ as: 'global' }, context => { + // On Node.js, the root span is created by the HTTP instrumentation and only has the raw URL. + // The Elysia OTel plugin creates a child span with route info, but we need to propagate it up. + // On Bun, the Elysia OTel plugin already handles the root span correctly. + if (!isBun() && context.route) { + updateRouteTransactionName(context.request.method, context.route); + } + + const traceData = getTraceData(); + if (traceData['sentry-trace']) { + context.set.headers['sentry-trace'] = traceData['sentry-trace']; + } + if (traceData.baggage) { + context.set.headers['baggage'] = traceData.baggage; + } + }); + + // Register the error handler for all routes + app.onError({ as: 'global' }, context => { + if (context.route) { + updateRouteTransactionName(context.request.method, context.route); + } + + const shouldHandleError = options?.shouldHandleError || defaultShouldHandleError; + if (shouldHandleError(context)) { + captureException(context.error, { + mechanism: { + type: 'auto.http.elysia.on_error', + handled: false, + }, + }); + } + }); + + return app; +} diff --git a/packages/elysia/test/sdk.test.ts b/packages/elysia/test/sdk.test.ts new file mode 100644 index 000000000000..d27011f0e416 --- /dev/null +++ b/packages/elysia/test/sdk.test.ts @@ -0,0 +1,124 @@ +import type { Integration } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockApplySdkMetadata = vi.fn(); +const mockInitNode = vi.fn(); +const mockGetBunDefaultIntegrations = vi.fn(() => [] as Integration[]); +const mockMakeFetchTransport = vi.fn(); + +vi.mock('@sentry/core', async importActual => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importActual(); + return { + ...actual, + applySdkMetadata: mockApplySdkMetadata, + }; +}); + +vi.mock('@sentry/bun', () => ({ + init: mockInitNode, + getDefaultIntegrations: mockGetBunDefaultIntegrations, + makeFetchTransport: mockMakeFetchTransport, + bunServerIntegration: () => ({ name: 'BunServer', setupOnce: vi.fn() }), +})); + +// Must import after mocks are set up +// @ts-expect-error - dynamic import +const { init, getDefaultIntegrations } = await import('../src/sdk'); + +describe('init', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('sets SDK metadata to elysia', () => { + init({ dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' }); + + expect(mockApplySdkMetadata).toHaveBeenCalledWith( + expect.objectContaining({ dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' }), + 'elysia', + ['elysia', 'node'], + ); + }); + + it('calls initNode with the options', () => { + init({ dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' }); + + expect(mockInitNode).toHaveBeenCalledWith( + expect.objectContaining({ + dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0', + platform: 'javascript', + }), + ); + }); + + it('uses makeFetchTransport by default', () => { + init({ dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' }); + + expect(mockInitNode).toHaveBeenCalledWith( + expect.objectContaining({ + transport: mockMakeFetchTransport, + }), + ); + }); + + it('allows overriding transport', () => { + const customTransport = vi.fn(); + init({ dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0', transport: customTransport }); + + expect(mockInitNode).toHaveBeenCalledWith( + expect.objectContaining({ + transport: customTransport, + }), + ); + }); + + it('sets default integrations from bun and filters out BunServer', () => { + const mockIntegration = { name: 'MockIntegration', setupOnce: vi.fn() }; + const bunServerMock = { name: 'BunServer', setupOnce: vi.fn() }; + mockGetBunDefaultIntegrations.mockReturnValueOnce([mockIntegration, bunServerMock]); + + init({ dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' }); + + expect(mockInitNode).toHaveBeenCalledWith( + expect.objectContaining({ + defaultIntegrations: [mockIntegration], + }), + ); + }); + + it('does not override user-provided defaultIntegrations', () => { + const userIntegrations = [{ name: 'UserIntegration', setupOnce: vi.fn() }]; + + init({ dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0', defaultIntegrations: userIntegrations }); + + expect(mockInitNode).toHaveBeenCalledWith( + expect.objectContaining({ + defaultIntegrations: userIntegrations, + }), + ); + expect(mockGetBunDefaultIntegrations).not.toHaveBeenCalled(); + }); + + it('detects runtime correctly', () => { + init({ dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' }); + + const calledOptions = mockInitNode.mock.calls[0]![0]; + // In vitest (Node), Bun is not defined, so runtime should be node + expect(calledOptions.runtime.name).toBe('node'); + expect(calledOptions.runtime.version).toBe(process.version); + }); +}); + +describe('getDefaultIntegrations', () => { + it('returns bun default integrations without BunServer', () => { + const mockIntegration = { name: 'MockIntegration', setupOnce: vi.fn() }; + const bunServerMock = { name: 'BunServer', setupOnce: vi.fn() }; + mockGetBunDefaultIntegrations.mockReturnValueOnce([mockIntegration, bunServerMock]); + + const integrations = getDefaultIntegrations({}); + + expect(integrations).toEqual([mockIntegration]); + expect(mockGetBunDefaultIntegrations).toHaveBeenCalledWith({}); + }); +}); diff --git a/packages/elysia/test/withElysia.test.ts b/packages/elysia/test/withElysia.test.ts new file mode 100644 index 000000000000..0bed50248af1 --- /dev/null +++ b/packages/elysia/test/withElysia.test.ts @@ -0,0 +1,184 @@ +import type { ErrorContext } from 'elysia'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Capture the handlers registered by withElysia +let onAfterHandleHandler: (context: unknown) => void; +let onErrorHandler: (context: unknown) => void; + +function createMockApp() { + const app: Record = {}; + app.use = vi.fn().mockReturnValue(app); + app.onRequest = vi.fn(() => app); + app.onAfterHandle = vi.fn((_opts: unknown, handler: (context: unknown) => void) => { + onAfterHandleHandler = handler; + return app; + }); + app.onError = vi.fn((_opts: unknown, handler: (context: unknown) => void) => { + onErrorHandler = handler; + return app; + }); + return app; +} + +let mockApp: ReturnType; + +const mockCaptureException = vi.fn(); +const mockGetIsolationScope = vi.fn(() => ({ + setSDKProcessingMetadata: vi.fn(), + setTransactionName: vi.fn(), +})); +const mockGetClient = vi.fn(() => ({ + on: vi.fn(), +})); +const mockGetTraceData = vi.fn(() => ({ + 'sentry-trace': 'abc123-def456-1', + baggage: 'sentry-environment=test,sentry-trace_id=abc123', +})); + +vi.mock('@elysiajs/opentelemetry', () => ({ + opentelemetry: vi.fn(() => 'otel-plugin'), +})); + +vi.mock('@sentry/core', async importActual => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importActual(); + return { + ...actual, + captureException: (...args: unknown[]) => mockCaptureException(...args), + getIsolationScope: () => mockGetIsolationScope(), + getClient: () => mockGetClient(), + getTraceData: () => mockGetTraceData(), + }; +}); + +// @ts-expect-error - dynamic import after mocks +const { withElysia } = await import('../src/withElysia'); + +describe('withElysia', () => { + beforeEach(() => { + mockApp = createMockApp(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('registers opentelemetry plugin', () => { + // @ts-expect-error - mock app + withElysia(mockApp); + expect(mockApp.use).toHaveBeenCalledWith('otel-plugin'); + }); + + it('registers onRequest, onAfterHandle, and onError hooks', () => { + // @ts-expect-error - mock app + withElysia(mockApp); + expect(mockApp.onRequest).toHaveBeenCalled(); + expect(mockApp.onAfterHandle).toHaveBeenCalledWith({ as: 'global' }, expect.any(Function)); + expect(mockApp.onError).toHaveBeenCalledWith({ as: 'global' }, expect.any(Function)); + }); + + it('returns the app instance for chaining', () => { + // @ts-expect-error - mock app + const result = withElysia(mockApp); + expect(result).toBe(mockApp); + }); + + describe('response trace headers', () => { + it('injects sentry-trace and baggage into response headers', () => { + // @ts-expect-error - mock app + withElysia(mockApp); + const headers: Record = {}; + onAfterHandleHandler({ set: { headers } }); + + expect(headers['sentry-trace']).toBe('abc123-def456-1'); + expect(headers['baggage']).toBe('sentry-environment=test,sentry-trace_id=abc123'); + }); + + it('does not set headers when trace data is empty', () => { + mockGetTraceData.mockReturnValueOnce({}); + // @ts-expect-error - mock app + withElysia(mockApp); + const headers: Record = {}; + onAfterHandleHandler({ set: { headers } }); + + expect(headers['sentry-trace']).toBeUndefined(); + expect(headers['baggage']).toBeUndefined(); + }); + }); + + describe('defaultShouldHandleError', () => { + function triggerError(status: number | string | undefined): void { + // @ts-expect-error - mock app + withElysia(mockApp); + onErrorHandler({ + route: '/test', + request: { method: 'GET' }, + error: new Error('test'), + set: { status }, + } as unknown as ErrorContext); + } + + it('captures errors with status >= 500', () => { + triggerError(500); + expect(mockCaptureException).toHaveBeenCalled(); + }); + + it('captures errors with status 503', () => { + triggerError(503); + expect(mockCaptureException).toHaveBeenCalled(); + }); + + it('captures errors with undefined status', () => { + triggerError(undefined); + expect(mockCaptureException).toHaveBeenCalled(); + }); + + it('captures errors with status <= 299 (unusual in error handler)', () => { + triggerError(200); + expect(mockCaptureException).toHaveBeenCalled(); + }); + + it('does not capture 4xx errors', () => { + triggerError(400); + expect(mockCaptureException).not.toHaveBeenCalled(); + }); + + it('does not capture 404 errors', () => { + triggerError(404); + expect(mockCaptureException).not.toHaveBeenCalled(); + }); + + it('does not capture 3xx responses', () => { + triggerError(302); + expect(mockCaptureException).not.toHaveBeenCalled(); + }); + + it('handles string status codes', () => { + triggerError('500'); + expect(mockCaptureException).toHaveBeenCalled(); + }); + + it('does not capture string 4xx status codes', () => { + triggerError('400'); + expect(mockCaptureException).not.toHaveBeenCalled(); + }); + }); + + describe('custom shouldHandleError', () => { + it('uses custom shouldHandleError when provided', () => { + const customShouldHandle = vi.fn(() => false); + // @ts-expect-error - mock app + withElysia(mockApp, { shouldHandleError: customShouldHandle }); + + onErrorHandler({ + route: '/test', + request: { method: 'GET' }, + error: new Error('test'), + set: { status: 500 }, + } as unknown as ErrorContext); + + expect(customShouldHandle).toHaveBeenCalled(); + expect(mockCaptureException).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/elysia/tsconfig.json b/packages/elysia/tsconfig.json new file mode 100644 index 000000000000..dcbef254b942 --- /dev/null +++ b/packages/elysia/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + + "include": ["src/**/*"], + + "compilerOptions": { + // package-specific options + "types": ["bun-types"] + } +} diff --git a/packages/elysia/tsconfig.test.json b/packages/elysia/tsconfig.test.json new file mode 100644 index 000000000000..4cbbffaccbbc --- /dev/null +++ b/packages/elysia/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + + "include": ["test/**/*"], + + "compilerOptions": { + // should include all types from `./tsconfig.json` plus types for all test frameworks used + "types": ["bun-types"] + + // other package-specific, test-specific options + } +} diff --git a/packages/elysia/tsconfig.types.json b/packages/elysia/tsconfig.types.json new file mode 100644 index 000000000000..65455f66bd75 --- /dev/null +++ b/packages/elysia/tsconfig.types.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "build/types" + } +} diff --git a/yarn.lock b/yarn.lock index da64ae001539..375e86ddd052 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3272,6 +3272,15 @@ resolved "https://registry.yarnpkg.com/@effect/vitest/-/vitest-0.23.13.tgz#17edf9d8e3443f080ff8fe93bd37b023612a07a4" integrity sha512-F3x2phMXuVzqWexdcYp8v0z1qQHkKxp2UaHNbqZaEjPEp8FBz/iMwbi6iS/oIWzLfGF8XqdP8BGJptvGIJONNw== +"@elysiajs/opentelemetry@^1.4.0": + version "1.4.10" + resolved "https://registry.yarnpkg.com/@elysiajs/opentelemetry/-/opentelemetry-1.4.10.tgz#1c107d9071fbc284737f7b47cdf15554ef075521" + integrity sha512-2GH187Rr3n3Rq+R7fogn/jcmdwWk9OMtbYhnaJg5ydiLOJvtrztDp0p+zbyGFG2gspx8U9vpaCvSJ69Aq1zZkA== + dependencies: + "@opentelemetry/api" "^1.9.0" + "@opentelemetry/instrumentation" "^0.200.0" + "@opentelemetry/sdk-node" "^0.200.0" + "@ember-data/rfc395-data@^0.0.4": version "0.0.4" resolved "https://registry.yarnpkg.com/@ember-data/rfc395-data/-/rfc395-data-0.0.4.tgz#ecb86efdf5d7733a76ff14ea651a1b0ed1f8a843" @@ -4636,6 +4645,24 @@ dependencies: dom-mutator "^0.6.0" +"@grpc/grpc-js@^1.7.1": + version "1.14.3" + resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.14.3.tgz#4c9b817a900ae4020ddc28515ae4b52c78cfb8da" + integrity sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA== + dependencies: + "@grpc/proto-loader" "^0.8.0" + "@js-sdsl/ordered-map" "^4.4.2" + +"@grpc/proto-loader@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.8.0.tgz#b6c324dd909c458a0e4aa9bfd3d69cf78a4b9bd8" + integrity sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ== + dependencies: + lodash.camelcase "^4.3.0" + long "^5.0.0" + protobufjs "^7.5.3" + yargs "^17.7.2" + "@handlebars/parser@~2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@handlebars/parser/-/parser-2.0.0.tgz#5e8b7298f31ff8f7b260e6b7363c7e9ceed7d9c5" @@ -5205,6 +5232,11 @@ resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-5.7.0.tgz#526d437b07cbb41e28df34d487cbfccbe730185b" integrity sha512-WBu4ULVVxySLLzK1Ppq+OdfP+adRS4ntmDQT915rzDJ++i95gc2jZkM5B6LWEAwN3lGXpfie3yPABozdD3K3Vg== +"@js-sdsl/ordered-map@^4.4.2": + version "4.4.2" + resolved "https://registry.yarnpkg.com/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz#9299f82874bab9e4c7f9c48d865becbfe8d6907c" + integrity sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw== + "@kwsites/file-exists@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@kwsites/file-exists/-/file-exists-1.1.1.tgz#ad1efcac13e1987d8dbaf235ef3be5b0d96faa99" @@ -6187,17 +6219,10 @@ dependencies: "@octokit/openapi-types" "^12.11.0" -"@opentelemetry/api-logs@0.207.0": - version "0.207.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz#ae991c51eedda55af037a3e6fc1ebdb12b289f49" - integrity sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ== - dependencies: - "@opentelemetry/api" "^1.3.0" - -"@opentelemetry/api-logs@0.212.0": - version "0.212.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.212.0.tgz#ec66a0951b84b1f082e13fd8a027b9f9d65a3f7a" - integrity sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg== +"@opentelemetry/api-logs@0.200.0": + version "0.200.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.200.0.tgz#f9015fd844920c13968715b3cdccf5a4d4ff907e" + integrity sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q== dependencies: "@opentelemetry/api" "^1.3.0" @@ -6213,18 +6238,150 @@ resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe" integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg== +"@opentelemetry/context-async-hooks@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/context-async-hooks/-/context-async-hooks-2.0.0.tgz#c98a727238ca199cda943780acf6124af8d8cd80" + integrity sha512-IEkJGzK1A9v3/EHjXh3s2IiFc6L4jfK+lNgKVgUjeUJQRRhnVFMIO3TAvKwonm9O1HebCuoOt98v8bZW7oVQHA== + "@opentelemetry/context-async-hooks@^2.6.0": version "2.6.0" resolved "https://registry.yarnpkg.com/@opentelemetry/context-async-hooks/-/context-async-hooks-2.6.0.tgz#6c824e900630b378233c1a78ca7f0dc5a3b460b2" integrity sha512-L8UyDwqpTcbkIK5cgwDRDYDoEhQoj8wp8BwsO19w3LB1Z41yEQm2VJyNfAi9DrLP/YTqXqWpKHyZfR9/tFYo1Q== -"@opentelemetry/core@2.6.0", "@opentelemetry/core@^2.0.0", "@opentelemetry/core@^2.6.0": +"@opentelemetry/core@2.0.0", "@opentelemetry/core@2.6.0", "@opentelemetry/core@^2.0.0", "@opentelemetry/core@^2.6.0": version "2.6.0" resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-2.6.0.tgz#719c829ed98bd7af808a2d2c83374df1fd1f3c66" integrity sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg== dependencies: "@opentelemetry/semantic-conventions" "^1.29.0" +"@opentelemetry/exporter-logs-otlp-grpc@0.200.0": + version "0.200.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.200.0.tgz#693e0f7041c533061d0689ab43d64d039078ee7a" + integrity sha512-+3MDfa5YQPGM3WXxW9kqGD85Q7s9wlEMVNhXXG7tYFLnIeaseUt9YtCeFhEDFzfEktacdFpOtXmJuNW8cHbU5A== + dependencies: + "@grpc/grpc-js" "^1.7.1" + "@opentelemetry/core" "2.0.0" + "@opentelemetry/otlp-exporter-base" "0.200.0" + "@opentelemetry/otlp-grpc-exporter-base" "0.200.0" + "@opentelemetry/otlp-transformer" "0.200.0" + "@opentelemetry/sdk-logs" "0.200.0" + +"@opentelemetry/exporter-logs-otlp-http@0.200.0": + version "0.200.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.200.0.tgz#3a99c9554f871b5c6cddb8716316c125d4edca6c" + integrity sha512-KfWw49htbGGp9s8N4KI8EQ9XuqKJ0VG+yVYVYFiCYSjEV32qpQ5qZ9UZBzOZ6xRb+E16SXOSCT3RkqBVSABZ+g== + dependencies: + "@opentelemetry/api-logs" "0.200.0" + "@opentelemetry/core" "2.0.0" + "@opentelemetry/otlp-exporter-base" "0.200.0" + "@opentelemetry/otlp-transformer" "0.200.0" + "@opentelemetry/sdk-logs" "0.200.0" + +"@opentelemetry/exporter-logs-otlp-proto@0.200.0": + version "0.200.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.200.0.tgz#53573ea43bce4129bcb18bda172a95c6535bb1a2" + integrity sha512-GmahpUU/55hxfH4TP77ChOfftADsCq/nuri73I/AVLe2s4NIglvTsaACkFVZAVmnXXyPS00Fk3x27WS3yO07zA== + dependencies: + "@opentelemetry/api-logs" "0.200.0" + "@opentelemetry/core" "2.0.0" + "@opentelemetry/otlp-exporter-base" "0.200.0" + "@opentelemetry/otlp-transformer" "0.200.0" + "@opentelemetry/resources" "2.0.0" + "@opentelemetry/sdk-logs" "0.200.0" + "@opentelemetry/sdk-trace-base" "2.0.0" + +"@opentelemetry/exporter-metrics-otlp-grpc@0.200.0": + version "0.200.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.200.0.tgz#f9a4d209083a6a12489c4ae4c20e6923a1780c88" + integrity sha512-uHawPRvKIrhqH09GloTuYeq2BjyieYHIpiklOvxm9zhrCL2eRsnI/6g9v2BZTVtGp8tEgIa7rCQ6Ltxw6NBgew== + dependencies: + "@grpc/grpc-js" "^1.7.1" + "@opentelemetry/core" "2.0.0" + "@opentelemetry/exporter-metrics-otlp-http" "0.200.0" + "@opentelemetry/otlp-exporter-base" "0.200.0" + "@opentelemetry/otlp-grpc-exporter-base" "0.200.0" + "@opentelemetry/otlp-transformer" "0.200.0" + "@opentelemetry/resources" "2.0.0" + "@opentelemetry/sdk-metrics" "2.0.0" + +"@opentelemetry/exporter-metrics-otlp-http@0.200.0": + version "0.200.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.200.0.tgz#daa28a2b868bacf02efb153fa8780d078807919e" + integrity sha512-5BiR6i8yHc9+qW7F6LqkuUnIzVNA7lt0qRxIKcKT+gq3eGUPHZ3DY29sfxI3tkvnwMgtnHDMNze5DdxW39HsAw== + dependencies: + "@opentelemetry/core" "2.0.0" + "@opentelemetry/otlp-exporter-base" "0.200.0" + "@opentelemetry/otlp-transformer" "0.200.0" + "@opentelemetry/resources" "2.0.0" + "@opentelemetry/sdk-metrics" "2.0.0" + +"@opentelemetry/exporter-metrics-otlp-proto@0.200.0": + version "0.200.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.200.0.tgz#5a494e2df8703be2f1f5f01629dfd48a6d39e5a6" + integrity sha512-E+uPj0yyvz81U9pvLZp3oHtFrEzNSqKGVkIViTQY1rH3TOobeJPSpLnTVXACnCwkPR5XeTvPnK3pZ2Kni8AFMg== + dependencies: + "@opentelemetry/core" "2.0.0" + "@opentelemetry/exporter-metrics-otlp-http" "0.200.0" + "@opentelemetry/otlp-exporter-base" "0.200.0" + "@opentelemetry/otlp-transformer" "0.200.0" + "@opentelemetry/resources" "2.0.0" + "@opentelemetry/sdk-metrics" "2.0.0" + +"@opentelemetry/exporter-prometheus@0.200.0": + version "0.200.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.200.0.tgz#8f3dd3a8903447563a5be30ddf9e7bfb1e7ad127" + integrity sha512-ZYdlU9r0USuuYppiDyU2VFRA0kFl855ylnb3N/2aOlXrbA4PMCznen7gmPbetGQu7pz8Jbaf4fwvrDnVdQQXSw== + dependencies: + "@opentelemetry/core" "2.0.0" + "@opentelemetry/resources" "2.0.0" + "@opentelemetry/sdk-metrics" "2.0.0" + +"@opentelemetry/exporter-trace-otlp-grpc@0.200.0": + version "0.200.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.200.0.tgz#e259367f324c01342bf3f0175c52d9f4e61a345f" + integrity sha512-hmeZrUkFl1YMsgukSuHCFPYeF9df0hHoKeHUthRKFCxiURs+GwF1VuabuHmBMZnjTbsuvNjOB+JSs37Csem/5Q== + dependencies: + "@grpc/grpc-js" "^1.7.1" + "@opentelemetry/core" "2.0.0" + "@opentelemetry/otlp-exporter-base" "0.200.0" + "@opentelemetry/otlp-grpc-exporter-base" "0.200.0" + "@opentelemetry/otlp-transformer" "0.200.0" + "@opentelemetry/resources" "2.0.0" + "@opentelemetry/sdk-trace-base" "2.0.0" + +"@opentelemetry/exporter-trace-otlp-http@0.200.0": + version "0.200.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.200.0.tgz#ddf2bbdff5157a89f64aad6dad44c394872d589d" + integrity sha512-Goi//m/7ZHeUedxTGVmEzH19NgqJY+Bzr6zXo1Rni1+hwqaksEyJ44gdlEMREu6dzX1DlAaH/qSykSVzdrdafA== + dependencies: + "@opentelemetry/core" "2.0.0" + "@opentelemetry/otlp-exporter-base" "0.200.0" + "@opentelemetry/otlp-transformer" "0.200.0" + "@opentelemetry/resources" "2.0.0" + "@opentelemetry/sdk-trace-base" "2.0.0" + +"@opentelemetry/exporter-trace-otlp-proto@0.200.0": + version "0.200.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.200.0.tgz#f3f149e6bad8c899c8f1e5c58e5d855ce07f7319" + integrity sha512-V9TDSD3PjK1OREw2iT9TUTzNYEVWJk4Nhodzhp9eiz4onDMYmPy3LaGbPv81yIR6dUb/hNp/SIhpiCHwFUq2Vg== + dependencies: + "@opentelemetry/core" "2.0.0" + "@opentelemetry/otlp-exporter-base" "0.200.0" + "@opentelemetry/otlp-transformer" "0.200.0" + "@opentelemetry/resources" "2.0.0" + "@opentelemetry/sdk-trace-base" "2.0.0" + +"@opentelemetry/exporter-zipkin@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-zipkin/-/exporter-zipkin-2.0.0.tgz#6aca658d64f5e8bc079b07ee0a3076c4ca328ec9" + integrity sha512-icxaKZ+jZL/NHXX8Aru4HGsrdhK0MLcuRXkX5G5IRmCgoRLw+Br6I/nMVozX2xjGGwV7hw2g+4Slj8K7s4HbVg== + dependencies: + "@opentelemetry/core" "2.0.0" + "@opentelemetry/resources" "2.0.0" + "@opentelemetry/sdk-trace-base" "2.0.0" + "@opentelemetry/semantic-conventions" "^1.29.0" + "@opentelemetry/instrumentation-amqplib@0.60.0": version "0.60.0" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.60.0.tgz#a2b2abe3cf433bea166c18a703c8ddf6accf83da" @@ -6433,7 +6590,7 @@ "@opentelemetry/instrumentation" "^0.213.0" "@opentelemetry/semantic-conventions" "^1.24.0" -"@opentelemetry/instrumentation@0.213.0", "@opentelemetry/instrumentation@^0.213.0": +"@opentelemetry/instrumentation@0.200.0", "@opentelemetry/instrumentation@0.213.0", "@opentelemetry/instrumentation@^0.200.0", "@opentelemetry/instrumentation@^0.207.0", "@opentelemetry/instrumentation@^0.212.0", "@opentelemetry/instrumentation@^0.213.0": version "0.213.0" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.213.0.tgz#55362569efd0cba00aab9921a78dd20dfddf70b6" integrity sha512-3i9NdkET/KvQomeh7UaR/F4r9P25Rx6ooALlWXPIjypcEOUxksCmVu0zA70NBJWlrMW1rPr/LRidFAflLI+s/w== @@ -6442,30 +6599,57 @@ import-in-the-middle "^3.0.0" require-in-the-middle "^8.0.0" -"@opentelemetry/instrumentation@^0.207.0": - version "0.207.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.207.0.tgz#1a5a921c04f171ff28096fa320af713f3c87ec14" - integrity sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA== +"@opentelemetry/otlp-exporter-base@0.200.0": + version "0.200.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.200.0.tgz#906bcf2e59815c8ded732d328f6bc060fb7b0459" + integrity sha512-IxJgA3FD7q4V6gGq4bnmQM5nTIyMDkoGFGrBrrDjB6onEiq1pafma55V+bHvGYLWvcqbBbRfezr1GED88lacEQ== + dependencies: + "@opentelemetry/core" "2.0.0" + "@opentelemetry/otlp-transformer" "0.200.0" + +"@opentelemetry/otlp-grpc-exporter-base@0.200.0": + version "0.200.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.200.0.tgz#cfc6cfd4def7d47f84e43d438d75cb463c67bf0d" + integrity sha512-CK2S+bFgOZ66Bsu5hlDeOX6cvW5FVtVjFFbWuaJP0ELxJKBB6HlbLZQ2phqz/uLj1cWap5xJr/PsR3iGoB7Vqw== + dependencies: + "@grpc/grpc-js" "^1.7.1" + "@opentelemetry/core" "2.0.0" + "@opentelemetry/otlp-exporter-base" "0.200.0" + "@opentelemetry/otlp-transformer" "0.200.0" + +"@opentelemetry/otlp-transformer@0.200.0": + version "0.200.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-transformer/-/otlp-transformer-0.200.0.tgz#19afb2274554cb74e2d2b7e32a54a7f7d83c8642" + integrity sha512-+9YDZbYybOnv7sWzebWOeK6gKyt2XE7iarSyBFkwwnP559pEevKOUD8NyDHhRjCSp13ybh9iVXlMfcj/DwF/yw== + dependencies: + "@opentelemetry/api-logs" "0.200.0" + "@opentelemetry/core" "2.0.0" + "@opentelemetry/resources" "2.0.0" + "@opentelemetry/sdk-logs" "0.200.0" + "@opentelemetry/sdk-metrics" "2.0.0" + "@opentelemetry/sdk-trace-base" "2.0.0" + protobufjs "^7.3.0" + +"@opentelemetry/propagator-b3@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/propagator-b3/-/propagator-b3-2.0.0.tgz#1b6244ef2d08a70672521a9aff56e485bd607c17" + integrity sha512-blx9S2EI49Ycuw6VZq+bkpaIoiJFhsDuvFGhBIoH3vJ5oYjJ2U0s3fAM5jYft99xVIAv6HqoPtlP9gpVA2IZtA== dependencies: - "@opentelemetry/api-logs" "0.207.0" - import-in-the-middle "^2.0.0" - require-in-the-middle "^8.0.0" + "@opentelemetry/core" "2.0.0" -"@opentelemetry/instrumentation@^0.212.0": - version "0.212.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.212.0.tgz#238b6e3e2131217ff4acfe7e8e7b6ce1f0ac0ba0" - integrity sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg== +"@opentelemetry/propagator-jaeger@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/propagator-jaeger/-/propagator-jaeger-2.0.0.tgz#288d6767dea554db684fd5e144ad8653d83fd2ea" + integrity sha512-Mbm/LSFyAtQKP0AQah4AfGgsD+vsZcyreZoQ5okFBk33hU7AquU4TltgyL9dvaO8/Zkoud8/0gEvwfOZ5d7EPA== dependencies: - "@opentelemetry/api-logs" "0.212.0" - import-in-the-middle "^2.0.6" - require-in-the-middle "^8.0.0" + "@opentelemetry/core" "2.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.6.0", "@opentelemetry/resources@^2.6.0": +"@opentelemetry/resources@2.0.0", "@opentelemetry/resources@2.6.0", "@opentelemetry/resources@^2.6.0": version "2.6.0" resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-2.6.0.tgz#1a945dbb8986043d8b593c358d5d8e3de6becf5a" integrity sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ== @@ -6473,7 +6657,52 @@ "@opentelemetry/core" "2.6.0" "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/sdk-trace-base@^2.6.0": +"@opentelemetry/sdk-logs@0.200.0": + version "0.200.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-logs/-/sdk-logs-0.200.0.tgz#893d86cefa6f2c02a7cd03d5cb4a959eed3653d1" + integrity sha512-VZG870063NLfObmQQNtCVcdXXLzI3vOjjrRENmU37HYiPFa0ZXpXVDsTD02Nh3AT3xYJzQaWKl2X2lQ2l7TWJA== + dependencies: + "@opentelemetry/api-logs" "0.200.0" + "@opentelemetry/core" "2.0.0" + "@opentelemetry/resources" "2.0.0" + +"@opentelemetry/sdk-metrics@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.0.tgz#aba86060bc363c661ca286339c5b04590e298b69" + integrity sha512-Bvy8QDjO05umd0+j+gDeWcTaVa1/R2lDj/eOvjzpm8VQj1K1vVZJuyjThpV5/lSHyYW2JaHF2IQ7Z8twJFAhjA== + dependencies: + "@opentelemetry/core" "2.0.0" + "@opentelemetry/resources" "2.0.0" + +"@opentelemetry/sdk-node@^0.200.0": + version "0.200.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-node/-/sdk-node-0.200.0.tgz#033d0641da628f1537cf7442f41cd77c048923ae" + integrity sha512-S/YSy9GIswnhYoDor1RusNkmRughipvTCOQrlF1dzI70yQaf68qgf5WMnzUxdlCl3/et/pvaO75xfPfuEmCK5A== + dependencies: + "@opentelemetry/api-logs" "0.200.0" + "@opentelemetry/core" "2.0.0" + "@opentelemetry/exporter-logs-otlp-grpc" "0.200.0" + "@opentelemetry/exporter-logs-otlp-http" "0.200.0" + "@opentelemetry/exporter-logs-otlp-proto" "0.200.0" + "@opentelemetry/exporter-metrics-otlp-grpc" "0.200.0" + "@opentelemetry/exporter-metrics-otlp-http" "0.200.0" + "@opentelemetry/exporter-metrics-otlp-proto" "0.200.0" + "@opentelemetry/exporter-prometheus" "0.200.0" + "@opentelemetry/exporter-trace-otlp-grpc" "0.200.0" + "@opentelemetry/exporter-trace-otlp-http" "0.200.0" + "@opentelemetry/exporter-trace-otlp-proto" "0.200.0" + "@opentelemetry/exporter-zipkin" "2.0.0" + "@opentelemetry/instrumentation" "0.200.0" + "@opentelemetry/propagator-b3" "2.0.0" + "@opentelemetry/propagator-jaeger" "2.0.0" + "@opentelemetry/resources" "2.0.0" + "@opentelemetry/sdk-logs" "0.200.0" + "@opentelemetry/sdk-metrics" "2.0.0" + "@opentelemetry/sdk-trace-base" "2.0.0" + "@opentelemetry/sdk-trace-node" "2.0.0" + "@opentelemetry/semantic-conventions" "^1.29.0" + +"@opentelemetry/sdk-trace-base@2.0.0", "@opentelemetry/sdk-trace-base@2.6.0", "@opentelemetry/sdk-trace-base@^2.6.0": version "2.6.0" resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.0.tgz#d7e752a0906f2bcae3c1261e224aef3e3b3746f9" integrity sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ== @@ -6482,6 +6711,15 @@ "@opentelemetry/resources" "2.6.0" "@opentelemetry/semantic-conventions" "^1.29.0" +"@opentelemetry/sdk-trace-node@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.0.0.tgz#ef9f8ab77ccb41a9c9ff272f6bf4bb6999491f5b" + integrity sha512-omdilCZozUjQwY3uZRBwbaRMJ3p09l4t187Lsdf0dGMye9WKD4NGcpgZRvqhI1dwcH6og+YXQEtoO9Wx3ykilg== + dependencies: + "@opentelemetry/context-async-hooks" "2.0.0" + "@opentelemetry/core" "2.0.0" + "@opentelemetry/sdk-trace-base" "2.0.0" + "@opentelemetry/semantic-conventions@^1.24.0", "@opentelemetry/semantic-conventions@^1.27.0", "@opentelemetry/semantic-conventions@^1.28.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.40.0": version "1.40.0" resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz#10b2944ca559386590683392022a897eefd011d3" @@ -9749,12 +9987,12 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@>=10.0.0", "@types/node@>=18": - version "22.10.2" - resolved "https://registry.yarnpkg.com/@types/node/-/node-22.10.2.tgz#a485426e6d1fdafc7b0d4c7b24e2c78182ddabb9" - integrity sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ== +"@types/node@*", "@types/node@>=10.0.0", "@types/node@>=13.7.0", "@types/node@>=18": + version "25.4.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.4.0.tgz#f25d8467984d6667cc4c1be1e2f79593834aaedb" + integrity sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw== dependencies: - undici-types "~6.20.0" + undici-types "~7.18.0" "@types/node@^14.8.0": version "14.18.63" @@ -13973,7 +14211,7 @@ cookie@^0.7.1, cookie@^0.7.2, cookie@~0.7.1, cookie@~0.7.2: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== -cookie@^1.0.1, cookie@^1.0.2: +cookie@^1.0.1, cookie@^1.0.2, cookie@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.1.1.tgz#3bb9bdfc82369db9c2f69c93c9c3ceb310c88b3c" integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ== @@ -15146,6 +15384,16 @@ electron-to-chromium@^1.5.263: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz#142be1ab5e1cd5044954db0e5898f60a4960384e" integrity sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A== +elysia@^1.4.0: + version "1.4.27" + resolved "https://registry.yarnpkg.com/elysia/-/elysia-1.4.27.tgz#709f07f54c0d0400aab8a18bac4d94223203ec45" + integrity sha512-2UlmNEjPJVA/WZVPYKy+KdsrfFwwNlqSBW1lHz6i2AHc75k7gV4Rhm01kFeotH7PDiHIX2G8X3KnRPc33SGVIg== + dependencies: + cookie "^1.1.1" + exact-mirror "^0.2.7" + fast-decode-uri-component "^1.0.1" + memoirist "^0.4.0" + ember-auto-import@^2.5.0, ember-auto-import@^2.7.2: version "2.8.1" resolved "https://registry.yarnpkg.com/ember-auto-import/-/ember-auto-import-2.8.1.tgz#03977e87ce178e6f9e4f89809185ff8f0fee9fcb" @@ -16943,6 +17191,11 @@ events@^3.0.0, events@^3.2.0, events@^3.3.0: resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== +exact-mirror@^0.2.7: + version "0.2.7" + resolved "https://registry.yarnpkg.com/exact-mirror/-/exact-mirror-0.2.7.tgz#ee8e75c362a67ca0e07cb13fea92b61adaabfa29" + integrity sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg== + exec-sh@^0.3.2, exec-sh@^0.3.4: version "0.3.6" resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.3.6.tgz#ff264f9e325519a60cb5e273692943483cca63bc" @@ -17212,6 +17465,11 @@ fast-content-type-parse@^3.0.0: resolved "https://registry.yarnpkg.com/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz#5590b6c807cc598be125e6740a9fde589d2b7afb" integrity sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg== +fast-decode-uri-component@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz#46f8b6c22b30ff7a81357d4f59abfae938202543" + integrity sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg== + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -19241,16 +19499,6 @@ import-fresh@^3.2.1: parent-module "^1.0.0" resolve-from "^4.0.0" -import-in-the-middle@^2.0.0, import-in-the-middle@^2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz#1972337bfe020d05f6b5e020c13334567436324f" - integrity sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw== - dependencies: - acorn "^8.15.0" - acorn-import-attributes "^1.9.5" - cjs-module-lexer "^2.2.0" - module-details-from-path "^1.0.4" - import-in-the-middle@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/import-in-the-middle/-/import-in-the-middle-3.0.0.tgz#720c12b4c07ea58b32a54667e70a022e18cc36a3" @@ -20953,7 +21201,7 @@ lodash.assign@^3.2.0: lodash._createassigner "^3.0.0" lodash.keys "^3.0.0" -lodash.camelcase@^4.1.1: +lodash.camelcase@^4.1.1, lodash.camelcase@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" integrity sha1-soqmKIorn8ZRA1x3EfZathkDMaY= @@ -21167,7 +21415,7 @@ long@^4.0.0: resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA== -long@^5.3.2: +long@^5.0.0, long@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== @@ -21682,6 +21930,11 @@ memfs@^3.4.3: dependencies: fs-monkey "^1.0.4" +memoirist@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/memoirist/-/memoirist-0.4.0.tgz#7677aa70f8c2f7f0791f8af1b689495c8dbc906d" + integrity sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg== + memory-pager@^1.0.2: version "1.5.0" resolved "https://registry.yarnpkg.com/memory-pager/-/memory-pager-1.5.0.tgz#d8751655d22d384682741c972f2c3d6dfa3e66b5" @@ -25575,6 +25828,24 @@ property-information@^7.0.0: resolved "https://registry.yarnpkg.com/property-information/-/property-information-7.1.0.tgz#b622e8646e02b580205415586b40804d3e8bfd5d" integrity sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ== +protobufjs@^7.3.0, protobufjs@^7.5.3: + version "7.5.4" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.5.4.tgz#885d31fe9c4b37f25d1bb600da30b1c5b37d286a" + integrity sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg== + dependencies: + "@protobufjs/aspromise" "^1.1.2" + "@protobufjs/base64" "^1.1.2" + "@protobufjs/codegen" "^2.0.4" + "@protobufjs/eventemitter" "^1.1.0" + "@protobufjs/fetch" "^1.1.0" + "@protobufjs/float" "^1.0.2" + "@protobufjs/inquire" "^1.1.0" + "@protobufjs/path" "^1.1.2" + "@protobufjs/pool" "^1.1.0" + "@protobufjs/utf8" "^1.1.0" + "@types/node" ">=13.7.0" + long "^5.0.0" + proxy-addr@^2.0.7, proxy-addr@~2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" @@ -29593,10 +29864,10 @@ undici-types@~5.26.4: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== -undici-types@~6.20.0: - version "6.20.0" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433" - integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== +undici-types@~7.18.0: + version "7.18.2" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.18.2.tgz#29357a89e7b7ca4aef3bf0fd3fd0cd73884229e9" + integrity sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w== undici@7.18.2: version "7.18.2" @@ -31318,7 +31589,7 @@ yargs@^16.1.1, yargs@^16.2.0: y18n "^5.0.5" yargs-parser "^20.2.2" -yargs@^17.2.1, yargs@^17.5.1, yargs@^17.6.0, yargs@^17.6.2: +yargs@^17.2.1, yargs@^17.5.1, yargs@^17.6.0, yargs@^17.6.2, yargs@^17.7.2: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== From 0156846951a0be11fd0f61a2e1694a06d85c5f38 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:44:06 +0100 Subject: [PATCH 40/43] feat(nuxt): Conditionally use plugins based on Nitro version (v2/v3) (#19955) Conditionally uses plugins for storage and database based on the Nitro version. Also adds a Nuxt 5 E2E test app, which skips the middleware tests (and some others) for now to be implemented in another PR. Or regarding Pinia, we need to wait until they support that. The E2E test app is mostly the same as the Nuxt 4 app (just migrated to Nitro v3) - explicit nitro imports instead of `#import` - `defineHandler` instead of `defineEventHandler` - `event.res?.headers.get()` instead of `getHeader()` Closes https://github.com/getsentry/sentry-javascript/issues/19275 --- .../test-applications/nuxt-5/.gitignore | 24 ++ .../e2e-tests/test-applications/nuxt-5/.npmrc | 2 + .../nuxt-5/app/components/ErrorButton.vue | 22 ++ .../app/composables/use-sentry-test-tag.ts | 8 + .../nuxt-5/app/pages/client-error.vue | 16 + .../nuxt-5/app/pages/fetch-server-routes.vue | 18 + .../nuxt-5/app/pages/index.vue | 20 ++ .../rendering-modes/client-side-only-page.vue | 1 + .../rendering-modes/isr-1h-cached-page.vue | 1 + .../pages/rendering-modes/isr-cached-page.vue | 1 + .../rendering-modes/pre-rendered-page.vue | 1 + .../rendering-modes/swr-1h-cached-page.vue | 1 + .../pages/rendering-modes/swr-cached-page.vue | 1 + .../nuxt-5/app/pages/test-param/[param].vue | 22 ++ .../app/pages/test-param/user/[userId].vue | 16 + .../nuxt-5/modules/another-module.ts | 9 + .../nuxt-5/nuxt-start-dev-server.bash | 46 +++ .../test-applications/nuxt-5/nuxt.config.ts | 47 +++ .../test-applications/nuxt-5/package.json | 38 ++ .../nuxt-5/playwright.config.ts | 25 ++ .../nuxt-5/public/favicon.ico | Bin 0 -> 4286 bytes .../nuxt-5/sentry.client.config.ts | 23 ++ .../nuxt-5/sentry.server.config.ts | 7 + .../nuxt-5/server/api/cache-test.ts | 86 +++++ .../nuxt-5/server/api/db-multi-test.ts | 104 ++++++ .../nuxt-5/server/api/db-test.ts | 72 ++++ .../nuxt-5/server/api/middleware-test.ts | 15 + .../nuxt-5/server/api/nitro-fetch.ts | 5 + .../nuxt-5/server/api/param-error/[param].ts | 5 + .../nuxt-5/server/api/server-error.ts | 5 + .../nuxt-5/server/api/storage-aliases-test.ts | 46 +++ .../nuxt-5/server/api/storage-test.ts | 54 +++ .../nuxt-5/server/api/test-param/[param].ts | 8 + .../nuxt-5/server/api/user/[userId].ts | 8 + .../nuxt-5/server/middleware/01.first.ts | 6 + .../nuxt-5/server/middleware/02.second.ts | 6 + .../nuxt-5/server/middleware/03.auth.ts | 13 + .../nuxt-5/server/middleware/04.hooks.ts | 37 ++ .../server/middleware/05.array-hooks.ts | 48 +++ .../nuxt-5/server/tsconfig.json | 3 + .../nuxt-5/start-event-proxy.mjs | 6 + .../nuxt-5/tests/cache.test.ts | 161 +++++++++ .../nuxt-5/tests/database-multi.test.ts | 156 ++++++++ .../nuxt-5/tests/database.test.ts | 197 +++++++++++ .../nuxt-5/tests/environment.test.ts | 77 ++++ .../nuxt-5/tests/errors.client.test.ts | 151 ++++++++ .../nuxt-5/tests/errors.server.test.ts | 66 ++++ .../nuxt-5/tests/isDevMode.ts | 1 + .../nuxt-5/tests/middleware.test.ts | 333 ++++++++++++++++++ .../nuxt-5/tests/pinia.test.ts | 36 ++ .../nuxt-5/tests/storage-aliases.test.ts | 108 ++++++ .../nuxt-5/tests/storage.test.ts | 151 ++++++++ .../nuxt-5/tests/tracing.cached-html.test.ts | 208 +++++++++++ .../nuxt-5/tests/tracing.client.test.ts | 57 +++ .../nuxt-5/tests/tracing.server.test.ts | 64 ++++ .../nuxt-5/tests/tracing.test.ts | 158 +++++++++ .../test-applications/nuxt-5/tsconfig.json | 4 + packages/nuxt/package.json | 7 +- packages/nuxt/src/module.ts | 28 +- .../nuxt/src/runtime/plugins/sentry.server.ts | 15 +- packages/nuxt/src/vite/databaseConfig.ts | 12 +- packages/nuxt/src/vite/storageConfig.ts | 8 +- packages/nuxt/src/vite/utils.ts | 22 +- .../nuxt/test/vite/databaseConfig.test.ts | 10 +- packages/nuxt/test/vite/utils.test.ts | 10 + 65 files changed, 2886 insertions(+), 30 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/app/components/ErrorButton.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/app/composables/use-sentry-test-tag.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/client-error.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/fetch-server-routes.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/index.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/client-side-only-page.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/isr-1h-cached-page.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/isr-cached-page.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/pre-rendered-page.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/swr-1h-cached-page.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/swr-cached-page.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/test-param/[param].vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/test-param/user/[userId].vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/modules/another-module.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/nuxt-start-dev-server.bash create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/package.json create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/public/favicon.ico create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/server/api/cache-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-multi-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/server/api/middleware-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/server/api/nitro-fetch.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/server/api/param-error/[param].ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/server/api/server-error.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/server/api/storage-aliases-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/server/api/storage-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/server/api/test-param/[param].ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/server/api/user/[userId].ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/01.first.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/02.second.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/04.hooks.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/05.array-hooks.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/server/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/tests/cache.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/tests/database-multi.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/tests/database.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/tests/environment.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.client.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/tests/isDevMode.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/tests/pinia.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage-aliases.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.cached-html.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.client.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/tsconfig.json diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/.gitignore b/dev-packages/e2e-tests/test-applications/nuxt-5/.gitignore new file mode 100644 index 000000000000..4a7f73a2ed0d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/.gitignore @@ -0,0 +1,24 @@ +# Nuxt dev/build outputs +.output +.data +.nuxt +.nitro +.cache +dist + +# Node dependencies +node_modules + +# Logs +logs +*.log + +# Misc +.DS_Store +.fleet +.idea + +# Local env files +.env +.env.* +!.env.example diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/.npmrc b/dev-packages/e2e-tests/test-applications/nuxt-5/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/components/ErrorButton.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/components/ErrorButton.vue new file mode 100644 index 000000000000..b686436bee17 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/components/ErrorButton.vue @@ -0,0 +1,22 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/composables/use-sentry-test-tag.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/app/composables/use-sentry-test-tag.ts new file mode 100644 index 000000000000..0d6642ca3d8c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/composables/use-sentry-test-tag.ts @@ -0,0 +1,8 @@ +// fixme: this needs to be imported from @sentry/core, not @sentry/nuxt in dev mode (because of import-in-the-middle error) +// This could also be a problem with the specific setup of the pnpm E2E test setup, because this could not be reproduced outside of the E2E test. +// Related to this: https://github.com/getsentry/sentry-javascript/issues/15204#issuecomment-2948908130 +import { setTag } from '@sentry/nuxt'; + +export default function useSentryTestTag(): void { + setTag('test-tag', null); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/client-error.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/client-error.vue new file mode 100644 index 000000000000..7d9cce216273 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/client-error.vue @@ -0,0 +1,16 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/fetch-server-routes.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/fetch-server-routes.vue new file mode 100644 index 000000000000..089d77a2eee9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/fetch-server-routes.vue @@ -0,0 +1,18 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/index.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/index.vue new file mode 100644 index 000000000000..57a583eb43b1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/index.vue @@ -0,0 +1,20 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/client-side-only-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/client-side-only-page.vue new file mode 100644 index 000000000000..fb41b62b3308 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/client-side-only-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/isr-1h-cached-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/isr-1h-cached-page.vue new file mode 100644 index 000000000000..e702eca86715 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/isr-1h-cached-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/isr-cached-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/isr-cached-page.vue new file mode 100644 index 000000000000..780adc07de53 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/isr-cached-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/pre-rendered-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/pre-rendered-page.vue new file mode 100644 index 000000000000..25b423a4c442 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/pre-rendered-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/swr-1h-cached-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/swr-1h-cached-page.vue new file mode 100644 index 000000000000..24918924f4a9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/swr-1h-cached-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/swr-cached-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/swr-cached-page.vue new file mode 100644 index 000000000000..d0d8e7241968 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/swr-cached-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/test-param/[param].vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/test-param/[param].vue new file mode 100644 index 000000000000..019404aaf460 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/test-param/[param].vue @@ -0,0 +1,22 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/test-param/user/[userId].vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/test-param/user/[userId].vue new file mode 100644 index 000000000000..41daf0460b05 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/test-param/user/[userId].vue @@ -0,0 +1,16 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/modules/another-module.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/modules/another-module.ts new file mode 100644 index 000000000000..9c1a3ca80487 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/modules/another-module.ts @@ -0,0 +1,9 @@ +import { defineNuxtModule } from 'nuxt/kit'; + +// Just a fake module to check if the SDK works alongside other local Nuxt modules without breaking the build +export default defineNuxtModule({ + meta: { name: 'another-module' }, + setup() { + console.log('another-module setup called'); + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt-start-dev-server.bash b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt-start-dev-server.bash new file mode 100644 index 000000000000..a1831f1e8e76 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt-start-dev-server.bash @@ -0,0 +1,46 @@ +#!/bin/bash +# To enable Sentry in Nuxt dev, it needs the sentry.server.config.mjs file from the .nuxt folder. +# First, we need to start 'nuxt dev' to generate the file, and then start 'nuxt dev' again with the NODE_OPTIONS to have Sentry enabled. + +# Using a different port to avoid playwright already starting with the tests for port 3030 +TEMP_PORT=3035 + +# 1. Start dev in background - this generates .nuxt folder +pnpm dev -p $TEMP_PORT & +DEV_PID=$! + +# 2. Wait for the sentry.server.config.mjs file to appear +echo "Waiting for .nuxt/dev/sentry.server.config.mjs file..." +COUNTER=0 +while [ ! -f ".nuxt/dev/sentry.server.config.mjs" ] && [ $COUNTER -lt 30 ]; do + sleep 1 + ((COUNTER++)) +done + +if [ ! -f ".nuxt/dev/sentry.server.config.mjs" ]; then + echo "ERROR: .nuxt/dev/sentry.server.config.mjs file never appeared!" + echo "This usually means the Nuxt dev server failed to start or generate the file. Try to rerun the test." + pkill -P $DEV_PID || kill $DEV_PID + exit 1 +fi + +# 3. Cleanup +echo "Found .nuxt/dev/sentry.server.config.mjs, stopping 'nuxt dev' process..." +pkill -P $DEV_PID || kill $DEV_PID + +# Wait for port to be released +echo "Waiting for port $TEMP_PORT to be released..." +COUNTER=0 +# Check if port is still in use +while lsof -i :$TEMP_PORT > /dev/null 2>&1 && [ $COUNTER -lt 10 ]; do + sleep 1 + ((COUNTER++)) +done + +if lsof -i :$TEMP_PORT > /dev/null 2>&1; then + echo "WARNING: Port $TEMP_PORT still in use after 10 seconds, proceeding anyway..." +else + echo "Port $TEMP_PORT released successfully" +fi + +echo "Nuxt dev server can now be started with '--import ./.nuxt/dev/sentry.server.config.mjs'" diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts new file mode 100644 index 000000000000..bdef334cfa88 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts @@ -0,0 +1,47 @@ +// https://nuxt.com/docs/api/configuration/nuxt-config +export default defineNuxtConfig({ + compatibilityDate: '2025-07-15', + imports: { autoImport: false }, + + routeRules: { + '/rendering-modes/client-side-only-page': { ssr: false }, + '/rendering-modes/isr-cached-page': { isr: true }, + '/rendering-modes/isr-1h-cached-page': { isr: 3600 }, + '/rendering-modes/swr-cached-page': { swr: true }, + '/rendering-modes/swr-1h-cached-page': { swr: 3600 }, + '/rendering-modes/pre-rendered-page': { prerender: true }, + }, + + modules: ['@pinia/nuxt', '@sentry/nuxt/module'], + runtimeConfig: { + public: { + sentry: { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }, + }, + }, + nitro: { + experimental: { + database: true, + }, + database: { + default: { + connector: 'sqlite', + options: { name: 'db' }, + }, + users: { + connector: 'sqlite', + options: { name: 'users_db' }, + }, + analytics: { + connector: 'sqlite', + options: { name: 'analytics_db' }, + }, + }, + storage: { + 'test-storage': { + driver: 'memory', + }, + }, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json new file mode 100644 index 000000000000..ad5b209a6b22 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json @@ -0,0 +1,38 @@ +{ + "name": "nuxt-5", + "private": true, + "type": "module", + "scripts": { + "build": "nuxt build", + "dev": "nuxt dev", + "generate": "nuxt generate", + "preview": "nuxt preview", + "start": "node .output/server/index.mjs", + "start:import": "node --import ./.output/server/sentry.server.config.mjs .output/server/index.mjs", + "clean": "npx nuxi cleanup", + "test": "playwright test", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "bash ./nuxt-start-dev-server.bash && TEST_ENV=development playwright test environment", + "test:build": "pnpm install && pnpm build", + "test:build-canary": "pnpm add nuxt@npm:nuxt-nightly@latest && pnpm add nitro@npm:nitro-nightly@latest && pnpm install --force && pnpm build", + "test:assert": "pnpm test:prod && pnpm test:dev" + }, + "//": [ + "Currently, we need to install the latest version of Nitro and the Nuxt nightlies as those contain Nuxt v5", + "TODO: remove nitro from dependencies" + ], + "dependencies": { + "@pinia/nuxt": "^0.11.3", + "@sentry/nuxt": "latest || *", + "nitro": "latest", + "nuxt": "npm:nuxt-nightly@5x" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json", + "node": "22.20.0" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/playwright.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/playwright.config.ts new file mode 100644 index 000000000000..b86690ca086c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/playwright.config.ts @@ -0,0 +1,25 @@ +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') { + return "NODE_OPTIONS='--import ./.nuxt/dev/sentry.server.config.mjs' nuxt dev -p 3030"; + } + + if (testEnv === 'production') { + return 'pnpm start:import'; + } + + throw new Error(`Unknown test env: ${testEnv}`); +}; + +const config = getPlaywrightConfig({ + startCommand: getStartCommand(), +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/public/favicon.ico b/dev-packages/e2e-tests/test-applications/nuxt-5/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..18993ad91cfd43e03b074dd0b5cc3f37ab38e49c GIT binary patch literal 4286 zcmeHLOKuuL5PjK%MHWVi6lD zOGiREbCw`xmFozJ^aNatJY>w+g ze6a2@u~m#^BZm@8wco9#Crlli0uLb^3E$t2-WIc^#(?t)*@`UpuofJ(Uyh@F>b3Ph z$D^m8Xq~pTkGJ4Q`Q2)te3mgkWYZ^Ijq|hkiP^9`De={bQQ%heZC$QU2UpP(-tbl8 zPWD2abEew;oat@w`uP3J^YpsgT%~jT(Dk%oU}sa$7|n6hBjDj`+I;RX(>)%lm_7N{+B7Mu%H?422lE%MBJH!!YTN2oT7xr>>N-8OF$C&qU^ z>vLsa{$0X%q1fjOe3P1mCv#lN{xQ4_*HCSAZjTb1`}mlc+9rl8$B3OP%VT@mch_~G z7Y+4b{r>9e=M+7vSI;BgB?ryZDY4m>&wcHSn81VH1N~`0gvwH{ z8dv#hG|OK`>1;j7tM#B)Z7zDN?{6=dUal}$e `${action}.transformed`, + stateTransformer: state => ({ + transformed: true, + ...state, + }), + }), + */ + Sentry.vueIntegration({ + tracingOptions: { + trackComponents: true, + }, + }), + ], +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts new file mode 100644 index 000000000000..26519911072b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/nuxt'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, // Capture 100% of the transactions + tunnel: 'http://localhost:3031/', // proxy server +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/cache-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/cache-test.ts new file mode 100644 index 000000000000..1f537ec4fee7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/cache-test.ts @@ -0,0 +1,86 @@ +import { defineHandler } from 'nitro'; +import { getQuery } from 'nitro/h3'; +import { defineCachedFunction, defineCachedHandler } from 'nitro/cache'; + +// Test cachedFunction +const getCachedUser = defineCachedFunction( + async (userId: string) => { + return { + id: userId, + name: `User ${userId}`, + email: `user${userId}@example.com`, + timestamp: Date.now(), + }; + }, + { + maxAge: 60, + name: 'getCachedUser', + getKey: (userId: string) => `user:${userId}`, + }, +); + +// Test cachedFunction with different options +const getCachedData = defineCachedFunction( + async (key: string) => { + return { + key, + value: `cached-value-${key}`, + timestamp: Date.now(), + }; + }, + { + maxAge: 120, + name: 'getCachedData', + getKey: (key: string) => `data:${key}`, + }, +); + +// Test defineCachedEventHandler +const cachedHandler = defineCachedHandler( + async event => { + return { + message: 'This response is cached', + timestamp: Date.now(), + path: event.path, + }; + }, + { + maxAge: 60, + name: 'cachedHandler', + }, +); + +export default defineHandler(async event => { + const results: Record = {}; + const testKey = String(getQuery(event).user ?? '123'); + const dataKey = String(getQuery(event).data ?? 'test-key'); + + // Test cachedFunction - first call (cache miss) + const user1 = await getCachedUser(testKey); + results.cachedUser1 = user1; + + // Test cachedFunction - second call (cache hit) + const user2 = await getCachedUser(testKey); + results.cachedUser2 = user2; + + // Test cachedFunction with different key (cache miss) + const user3 = await getCachedUser(`${testKey}456`); + results.cachedUser3 = user3; + + // Test another cachedFunction + const data1 = await getCachedData(dataKey); + results.cachedData1 = data1; + + // Test cachedFunction - cache hit + const data2 = await getCachedData(dataKey); + results.cachedData2 = data2; + + // Test cachedEventHandler by calling it + const cachedResponse = await cachedHandler(event); + results.cachedResponse = cachedResponse; + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-multi-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-multi-test.ts new file mode 100644 index 000000000000..8fbe09098e8c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-multi-test.ts @@ -0,0 +1,104 @@ +import { defineHandler } from 'nitro'; +import { useDatabase } from 'nitro/database'; +import { getQuery } from 'nitro/h3'; + +export default defineHandler(async event => { + const query = getQuery(event); + const method = query.method as string; + + switch (method) { + case 'default-db': { + // Test default database instance + const db = useDatabase(); + await db.exec('CREATE TABLE IF NOT EXISTS default_table (id INTEGER PRIMARY KEY, data TEXT)'); + await db.exec(`INSERT OR REPLACE INTO default_table (id, data) VALUES (1, 'default data')`); + const stmt = db.prepare('SELECT * FROM default_table WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'default', result }; + } + + case 'users-db': { + // Test named database instance 'users' + const usersDb = useDatabase('users'); + await usersDb.exec( + 'CREATE TABLE IF NOT EXISTS user_profiles (id INTEGER PRIMARY KEY, username TEXT, email TEXT)', + ); + await usersDb.exec( + `INSERT OR REPLACE INTO user_profiles (id, username, email) VALUES (1, 'john_doe', 'john@example.com')`, + ); + const stmt = usersDb.prepare('SELECT * FROM user_profiles WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'users', result }; + } + + case 'analytics-db': { + // Test named database instance 'analytics' + const analyticsDb = useDatabase('analytics'); + await analyticsDb.exec( + 'CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY, event_name TEXT, count INTEGER)', + ); + await analyticsDb.exec(`INSERT OR REPLACE INTO events (id, event_name, count) VALUES (1, 'page_view', 100)`); + const stmt = analyticsDb.prepare('SELECT * FROM events WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'analytics', result }; + } + + case 'multiple-dbs': { + // Test operations across multiple databases in a single request + const defaultDb = useDatabase(); + const usersDb = useDatabase('users'); + const analyticsDb = useDatabase('analytics'); + + // Create tables and insert data in all databases + await defaultDb.exec('CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY, token TEXT)'); + await defaultDb.exec(`INSERT OR REPLACE INTO sessions (id, token) VALUES (1, 'session-token-123')`); + + await usersDb.exec('CREATE TABLE IF NOT EXISTS accounts (id INTEGER PRIMARY KEY, account_name TEXT)'); + await usersDb.exec(`INSERT OR REPLACE INTO accounts (id, account_name) VALUES (1, 'Premium Account')`); + + await analyticsDb.exec( + 'CREATE TABLE IF NOT EXISTS metrics (id INTEGER PRIMARY KEY, metric_name TEXT, value REAL)', + ); + await analyticsDb.exec( + `INSERT OR REPLACE INTO metrics (id, metric_name, value) VALUES (1, 'conversion_rate', 0.25)`, + ); + + // Query from all databases + const sessionResult = await defaultDb.prepare('SELECT * FROM sessions WHERE id = ?').get(1); + const accountResult = await usersDb.prepare('SELECT * FROM accounts WHERE id = ?').get(1); + const metricResult = await analyticsDb.prepare('SELECT * FROM metrics WHERE id = ?').get(1); + + return { + success: true, + results: { + default: sessionResult, + users: accountResult, + analytics: metricResult, + }, + }; + } + + case 'sql-template-multi': { + // Test SQL template tag across multiple databases + const defaultDb = useDatabase(); + const usersDb = useDatabase('users'); + + await defaultDb.exec('CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY, message TEXT)'); + await usersDb.exec('CREATE TABLE IF NOT EXISTS audit_logs (id INTEGER PRIMARY KEY, action TEXT)'); + + const defaultResult = await defaultDb.sql`INSERT INTO logs (message) VALUES (${'test message'})`; + const usersResult = await usersDb.sql`INSERT INTO audit_logs (action) VALUES (${'user_login'})`; + + return { + success: true, + results: { + default: defaultResult, + users: usersResult, + }, + }; + } + + default: + return { error: 'Unknown method' }; + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-test.ts new file mode 100644 index 000000000000..6e17444c30bf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-test.ts @@ -0,0 +1,72 @@ +import { defineHandler } from 'nitro'; +import { getQuery } from 'nitro/h3'; +import { useDatabase } from 'nitro/database'; + +export default defineHandler(async event => { + const db = useDatabase(); + const query = getQuery(event); + const method = query.method as string; + + switch (method) { + case 'prepare-get': { + await db.exec('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)'); + await db.exec(`INSERT OR REPLACE INTO users (id, name, email) VALUES (1, 'Test User', 'test@example.com')`); + const stmt = db.prepare('SELECT * FROM users WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, result }; + } + + case 'prepare-all': { + await db.exec('CREATE TABLE IF NOT EXISTS products (id INTEGER PRIMARY KEY, name TEXT, price REAL)'); + await db.exec(`INSERT OR REPLACE INTO products (id, name, price) VALUES + (1, 'Product A', 10.99), + (2, 'Product B', 20.50), + (3, 'Product C', 15.25)`); + const stmt = db.prepare('SELECT * FROM products WHERE price > ?'); + const results = await stmt.all(10); + return { success: true, count: results.length, results }; + } + + case 'prepare-run': { + await db.exec('CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, customer TEXT, amount REAL)'); + const stmt = db.prepare('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + const result = await stmt.run('John Doe', 99.99); + return { success: true, result }; + } + + case 'prepare-bind': { + await db.exec('CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, category TEXT, value INTEGER)'); + await db.exec(`INSERT OR REPLACE INTO items (id, category, value) VALUES + (1, 'electronics', 100), + (2, 'books', 50), + (3, 'electronics', 200)`); + const stmt = db.prepare('SELECT * FROM items WHERE category = ?'); + const boundStmt = stmt.bind('electronics'); + const results = await boundStmt.all(); + return { success: true, count: results.length, results }; + } + + case 'sql': { + await db.exec('CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY, content TEXT, created_at TEXT)'); + const timestamp = new Date().toISOString(); + const results = await db.sql`INSERT INTO messages (content, created_at) VALUES (${'Hello World'}, ${timestamp})`; + return { success: true, results }; + } + + case 'exec': { + await db.exec('DROP TABLE IF EXISTS logs'); + await db.exec('CREATE TABLE logs (id INTEGER PRIMARY KEY, message TEXT, level TEXT)'); + const result = await db.exec(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + return { success: true, result }; + } + + case 'error': { + const stmt = db.prepare('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + await stmt.get(1); + return { success: false, message: 'Should have thrown an error' }; + } + + default: + return { error: 'Unknown method' }; + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/middleware-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/middleware-test.ts new file mode 100644 index 000000000000..6ac58ac0ebee --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/middleware-test.ts @@ -0,0 +1,15 @@ +import { defineHandler } from 'nitro'; + +export default defineHandler(async event => { + // Simple API endpoint that will trigger all server middleware + return { + message: 'Server middleware test endpoint', + path: event.path, + method: event.method, + headers: { + 'x-first-middleware': event.res?.headers.get('x-first-middleware'), + 'x-second-middleware': event.res?.headers.get('x-second-middleware'), + 'x-auth-middleware': event.res?.headers.get('x-auth-middleware'), + }, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/nitro-fetch.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/nitro-fetch.ts new file mode 100644 index 000000000000..8bc4cff56610 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/nitro-fetch.ts @@ -0,0 +1,5 @@ +import { defineHandler } from 'nitro'; + +export default defineHandler(async () => { + return await $fetch('https://example.com'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/param-error/[param].ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/param-error/[param].ts new file mode 100644 index 000000000000..3422c275abe0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/param-error/[param].ts @@ -0,0 +1,5 @@ +import { defineHandler } from 'nitro'; + +export default defineHandler(_e => { + throw new Error('Nuxt 4 Param Server error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/server-error.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/server-error.ts new file mode 100644 index 000000000000..23b89ce2c287 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/server-error.ts @@ -0,0 +1,5 @@ +import { defineHandler } from 'nitro'; + +export default defineHandler(event => { + throw new Error('Nuxt 4 Server error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/storage-aliases-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/storage-aliases-test.ts new file mode 100644 index 000000000000..eb41287ad23d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/storage-aliases-test.ts @@ -0,0 +1,46 @@ +import { useStorage } from 'nitro/storage'; +import { defineHandler } from 'nitro'; + +export default defineHandler(async _event => { + const storage = useStorage('test-storage'); + + // Test all alias methods (get, set, del, remove) + const results: Record = {}; + + // Test set (alias for setItem) + await storage.set('alias:user', { name: 'Jane Doe', role: 'admin' }); + results.set = 'success'; + + // Test get (alias for getItem) + const user = await storage.get('alias:user'); + results.get = user; + + // Test has (alias for hasItem) + const hasUser = await storage.has('alias:user'); + results.has = hasUser; + + // Setup for delete tests + await storage.set('alias:temp1', 'temp1'); + await storage.set('alias:temp2', 'temp2'); + + // Test del (alias for removeItem) + await storage.del('alias:temp1'); + results.del = 'success'; + + // Test remove (alias for removeItem) + await storage.remove('alias:temp2'); + results.remove = 'success'; + + // Verify deletions worked + const hasTemp1 = await storage.has('alias:temp1'); + const hasTemp2 = await storage.has('alias:temp2'); + results.verifyDeletions = !hasTemp1 && !hasTemp2; + + // Clean up + await storage.clear(); + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/storage-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/storage-test.ts new file mode 100644 index 000000000000..992f00fee4df --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/storage-test.ts @@ -0,0 +1,54 @@ +import { useStorage } from 'nitro/storage'; +import { defineHandler } from 'nitro'; + +export default defineHandler(async _event => { + const storage = useStorage('test-storage'); + + // Test all instrumented methods + const results: Record = {}; + + // Test setItem + await storage.setItem('user:123', { name: 'John Doe', email: 'john@example.com' }); + results.setItem = 'success'; + + // Test setItemRaw + await storage.setItemRaw('raw:data', Buffer.from('raw data')); + results.setItemRaw = 'success'; + + // Manually set batch items (setItems not supported by memory driver) + await storage.setItem('batch:1', 'value1'); + await storage.setItem('batch:2', 'value2'); + + // Test hasItem + const hasUser = await storage.hasItem('user:123'); + results.hasItem = hasUser; + + // Test getItem + const user = await storage.getItem('user:123'); + results.getItem = user; + + // Test getItemRaw + const rawData = await storage.getItemRaw('raw:data'); + results.getItemRaw = rawData?.toString(); + + // Test getKeys + const keys = await storage.getKeys('batch:'); + results.getKeys = keys; + + // Test removeItem + await storage.removeItem('batch:1'); + results.removeItem = 'success'; + + // Test clear + await storage.clear(); + results.clear = 'success'; + + // Verify clear worked + const keysAfterClear = await storage.getKeys(); + results.keysAfterClear = keysAfterClear; + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/test-param/[param].ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/test-param/[param].ts new file mode 100644 index 000000000000..e0ad305d2b9d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/test-param/[param].ts @@ -0,0 +1,8 @@ +import { defineHandler } from 'nitro'; +import { getRouterParam } from 'nitro/h3'; + +export default defineHandler(event => { + const param = getRouterParam(event, 'param'); + + return `Param: ${param}!`; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/user/[userId].ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/user/[userId].ts new file mode 100644 index 000000000000..d50d5d435912 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/user/[userId].ts @@ -0,0 +1,8 @@ +import { defineHandler } from 'nitro'; +import { getRouterParam } from 'nitro/h3'; + +export default defineHandler(event => { + const userId = getRouterParam(event, 'userId'); + + return `UserId Param: ${userId}!`; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/01.first.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/01.first.ts new file mode 100644 index 000000000000..9d86cbafcbbd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/01.first.ts @@ -0,0 +1,6 @@ +import { defineHandler } from 'nitro'; + +export default defineHandler(async event => { + // Set a header to indicate this middleware ran + event.res?.headers.set('x-first-middleware', 'executed'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/02.second.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/02.second.ts new file mode 100644 index 000000000000..01a184dfcc54 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/02.second.ts @@ -0,0 +1,6 @@ +import { defineHandler } from 'nitro'; + +export default defineHandler(async event => { + // Set a header to indicate this middleware ran + event.res?.headers.set('x-second-middleware', 'executed'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts new file mode 100644 index 000000000000..7216e9fc7560 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts @@ -0,0 +1,13 @@ +import { defineHandler } from 'nitro'; +import { getQuery } from 'nitro/h3'; + +export default defineHandler(async event => { + // Check if we should throw an error + const query = getQuery(event); + if (query.throwError === 'true') { + throw new Error('Auth middleware error'); + } + + // Set a header to indicate this middleware ran + event.res?.headers.set('x-auth-middleware', 'executed'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/04.hooks.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/04.hooks.ts new file mode 100644 index 000000000000..726cfaba8c10 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/04.hooks.ts @@ -0,0 +1,37 @@ +import { defineHandler } from 'nitro'; +import { getQuery } from 'nitro/h3'; + +export default defineHandler({ + onRequest: async event => { + // Set a header to indicate the onRequest hook ran + event.res?.headers.set('x-hooks-onrequest', 'executed'); + + // Check if we should throw an error in onRequest + const query = getQuery(event); + if (query.throwOnRequestError === 'true') { + throw new Error('OnRequest hook error'); + } + }, + + handler: async event => { + // Set a header to indicate the main handler ran + event.res?.headers.set('x-hooks-handler', 'executed'); + + // Check if we should throw an error in handler + const query = getQuery(event); + if (query.throwHandlerError === 'true') { + throw new Error('Handler error'); + } + }, + + onBeforeResponse: async (event, response) => { + // Set a header to indicate the onBeforeResponse hook ran + event.res?.headers.set('x-hooks-onbeforeresponse', 'executed'); + + // Check if we should throw an error in onBeforeResponse + const query = getQuery(event); + if (query.throwOnBeforeResponseError === 'true') { + throw new Error('OnBeforeResponse hook error'); + } + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/05.array-hooks.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/05.array-hooks.ts new file mode 100644 index 000000000000..f0bac6fb3113 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/05.array-hooks.ts @@ -0,0 +1,48 @@ +import { defineHandler } from 'nitro'; +import { getQuery } from 'nitro/h3'; + +export default defineHandler({ + // Array of onRequest handlers + onRequest: [ + async event => { + event.res?.headers.set('x-array-onrequest-0', 'executed'); + + const query = getQuery(event); + if (query.throwOnRequest0Error === 'true') { + throw new Error('OnRequest[0] hook error'); + } + }, + async event => { + event.res?.headers.set('x-array-onrequest-1', 'executed'); + + const query = getQuery(event); + if (query.throwOnRequest1Error === 'true') { + throw new Error('OnRequest[1] hook error'); + } + }, + ], + + handler: async event => { + event.res?.headers.set('x-array-handler', 'executed'); + }, + + // Array of onBeforeResponse handlers + onBeforeResponse: [ + async (event, response) => { + event.res?.headers.set('x-array-onbeforeresponse-0', 'executed'); + + const query = getQuery(event); + if (query.throwOnBeforeResponse0Error === 'true') { + throw new Error('OnBeforeResponse[0] hook error'); + } + }, + async (event, response) => { + event.res?.headers.set('x-array-onbeforeresponse-1', 'executed'); + + const query = getQuery(event); + if (query.throwOnBeforeResponse1Error === 'true') { + throw new Error('OnBeforeResponse[1] hook error'); + } + }, + ], +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/tsconfig.json b/dev-packages/e2e-tests/test-applications/nuxt-5/server/tsconfig.json new file mode 100644 index 000000000000..b9ed69c19eaf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../.nuxt/tsconfig.server.json" +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nuxt-5/start-event-proxy.mjs new file mode 100644 index 000000000000..1bff06b86eef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nuxt-5', +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/cache.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/cache.test.ts new file mode 100644 index 000000000000..7a660b88d714 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/cache.test.ts @@ -0,0 +1,161 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Cache Instrumentation', () => { + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments cachedFunction and cachedEventHandler calls and creates spans with correct attributes', async ({ + request, + }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/cache-test') ?? false; + }); + + const response = await request.get('/api/cache-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test that we have cache operations from cachedFunction and cachedEventHandler + const allCacheSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allCacheSpans?.length).toBeGreaterThan(0); + + // Test getItem spans for cachedFunction - should have both cache miss and cache hit + const getItemSpans = findSpansByOp('cache.get_item'); + expect(getItemSpans.length).toBeGreaterThan(0); + + // Find cache miss (first call to getCachedUser('123')) + const cacheMissSpan = getItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123') && + !span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT], + ); + if (cacheMissSpan) { + expect(cacheMissSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: false, + 'db.operation.name': 'getItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Find cache hit (second call to getCachedUser('123')) + const cacheHitSpan = getItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123') && + span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT], + ); + if (cacheHitSpan) { + expect(cacheHitSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Test setItem spans for cachedFunction - when cache miss occurs, value is set + const setItemSpans = findSpansByOp('cache.set_item'); + expect(setItemSpans.length).toBeGreaterThan(0); + + const cacheSetSpan = setItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123'), + ); + if (cacheSetSpan) { + expect(cacheSetSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'setItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Test that we have spans for different cached functions + const dataKeySpans = getItemSpans.filter( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('data:test-key'), + ); + expect(dataKeySpans.length).toBeGreaterThan(0); + + // Test that we have spans for cachedEventHandler + const cachedHandlerSpans = getItemSpans.filter( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('cachedHandler'), + ); + expect(cachedHandlerSpans.length).toBeGreaterThan(0); + + // Verify all cache spans have OK status + allCacheSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + + // Verify cache spans are properly nested under the transaction + allCacheSpans?.forEach(span => { + expect(span.parent_span_id).toBeDefined(); + }); + }); + + test('correctly tracks cache hits and misses for cachedFunction', async ({ request }) => { + // Use a unique key for this test to ensure fresh cache state + const uniqueUser = `test-${Date.now()}`; + const uniqueData = `data-${Date.now()}`; + + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/cache-test') ?? false; + }); + + await request.get(`/api/cache-test?user=${uniqueUser}&data=${uniqueData}`); + const transaction1 = await transactionPromise; + + // Get all cache-related spans + const allCacheSpans = transaction1.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + + // We should have cache operations + expect(allCacheSpans?.length).toBeGreaterThan(0); + + // Get all getItem operations + const allGetItemSpans = allCacheSpans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.get_item', + ); + + // Get all setItem operations + const allSetItemSpans = allCacheSpans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.set_item', + ); + + // We should have both get and set operations + expect(allGetItemSpans?.length).toBeGreaterThan(0); + expect(allSetItemSpans?.length).toBeGreaterThan(0); + + // Check for cache misses (cache.hit = false) + const cacheMissSpans = allGetItemSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT] === false); + + // Check for cache hits (cache.hit = true) + const cacheHitSpans = allGetItemSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT] === true); + + // We should have at least one cache miss (first calls to getCachedUser and getCachedData) + expect(cacheMissSpans?.length).toBeGreaterThanOrEqual(1); + + // We should have at least one cache hit (second calls to getCachedUser and getCachedData) + expect(cacheHitSpans?.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/database-multi.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/database-multi.test.ts new file mode 100644 index 000000000000..9257bbc0e8a2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/database-multi.test.ts @@ -0,0 +1,156 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('multiple database instances', () => { + test('instruments default database instance', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=default-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM default_table')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments named database instance (users)', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=users-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span from users database + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM user_profiles')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments named database instance (analytics)', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=analytics-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span from analytics database + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM events')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments multiple database instances in single request', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=multiple-dbs'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have spans from all three databases + const sessionSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM sessions')); + const accountSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM accounts')); + const metricSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM metrics')); + + expect(sessionSpan).toBeDefined(); + expect(sessionSpan?.op).toBe('db.query'); + expect(sessionSpan?.data?.['db.system.name']).toBe('sqlite'); + + expect(accountSpan).toBeDefined(); + expect(accountSpan?.op).toBe('db.query'); + expect(accountSpan?.data?.['db.system.name']).toBe('sqlite'); + + expect(metricSpan).toBeDefined(); + expect(metricSpan?.op).toBe('db.query'); + expect(metricSpan?.data?.['db.system.name']).toBe('sqlite'); + + // All should have the same origin + expect(sessionSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(accountSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(metricSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments SQL template tag across multiple databases', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=sql-template-multi'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have INSERT spans from both databases + const logsInsertSpan = dbSpans?.find(span => span.description?.includes('INSERT INTO logs')); + const auditLogsInsertSpan = dbSpans?.find(span => span.description?.includes('INSERT INTO audit_logs')); + + expect(logsInsertSpan).toBeDefined(); + expect(logsInsertSpan?.op).toBe('db.query'); + expect(logsInsertSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(logsInsertSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + + expect(auditLogsInsertSpan).toBeDefined(); + expect(auditLogsInsertSpan?.op).toBe('db.query'); + expect(auditLogsInsertSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(auditLogsInsertSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('creates correct span count for multiple database operations', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=multiple-dbs'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + // We should have multiple spans: + // - 3 CREATE TABLE (exec) spans + // - 3 INSERT (exec) spans + // - 3 SELECT (prepare + get) spans + // Total should be at least 9 spans + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThanOrEqual(9); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/database.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/database.test.ts new file mode 100644 index 000000000000..331b41d90ccf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/database.test.ts @@ -0,0 +1,197 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('database integration', () => { + test('captures db.prepare().get() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-get'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find(span => span.op === 'db.query' && span.description?.includes('SELECT')); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM users WHERE id = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM users WHERE id = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().all() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-all'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM products'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM products WHERE price > ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM products WHERE price > ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().run() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-run'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO orders'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().bind().all() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-bind'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM items'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM items WHERE category = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM items WHERE category = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.sql template tag span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=sql'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO messages'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toContain('INSERT INTO messages'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toContain('INSERT INTO messages'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.exec() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=exec'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO logs'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures database error and marks span as failed', async ({ request }) => { + const errorPromise = waitForError('nuxt-5', errorEvent => { + return !!errorEvent?.exception?.values?.[0]?.value?.includes('no such table'); + }); + + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=error').catch(() => { + // Expected to fail + }); + + const [error, transaction] = await Promise.all([errorPromise, transactionPromise]); + + expect(error).toBeDefined(); + expect(error.exception?.values?.[0]?.value).toContain('no such table'); + expect(error.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.db.nuxt', + }); + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM nonexistent_table'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(dbSpan?.status).toBe('internal_error'); + }); + + test('captures breadcrumb for db.exec() queries', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=exec'); + + const transaction = await transactionPromise; + + const dbBreadcrumb = transaction.breadcrumbs?.find( + breadcrumb => breadcrumb.category === 'query' && breadcrumb.message?.includes('INSERT INTO logs'), + ); + + expect(dbBreadcrumb).toBeDefined(); + expect(dbBreadcrumb?.category).toBe('query'); + expect(dbBreadcrumb?.message).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbBreadcrumb?.data?.['db.query.text']).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + }); + + test('multiple database operations in single request create multiple spans', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-get'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/environment.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/environment.test.ts new file mode 100644 index 000000000000..93f9935d048f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/environment.test.ts @@ -0,0 +1,77 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { isDevMode } from './isDevMode'; + +test.describe('environment detection', async () => { + test('sets correct environment for client-side errors', async ({ page }) => { + const errorPromise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from nuxt-5 E2E test app'; + }); + + // We have to wait for networkidle in dev mode because clicking the button is a no-op otherwise (network requests are blocked during page load) + await page.goto(`/client-error`, isDevMode ? { waitUntil: 'networkidle' } : {}); + await page.locator('#errorBtn').click(); + + const error = await errorPromise; + + if (isDevMode) { + expect(error.environment).toBe('development'); + } else { + expect(error.environment).toBe('production'); + } + }); + + test('sets correct environment for client-side transactions', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-5', async transactionEvent => { + return transactionEvent.transaction === '/test-param/:param()'; + }); + + await page.goto(`/test-param/1234`); + + const transaction = await transactionPromise; + + if (isDevMode) { + expect(transaction.environment).toBe('development'); + } else { + expect(transaction.environment).toBe('production'); + } + }); + + test('sets correct environment for server-side errors', async ({ page }) => { + const errorPromise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 4 Server error'; + }); + + await page.goto(`/fetch-server-routes`, isDevMode ? { waitUntil: 'networkidle' } : {}); + await page.getByText('Fetch Server API Error', { exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toBe('GET /api/server-error'); + + if (isDevMode) { + expect(error.environment).toBe('development'); + } else { + expect(error.environment).toBe('production'); + } + }); + + test('sets correct environment for server-side transactions', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-5', async transactionEvent => { + return transactionEvent.transaction === 'GET /api/nitro-fetch'; + }); + + await page.goto(`/fetch-server-routes`, isDevMode ? { waitUntil: 'networkidle' } : {}); + await page.getByText('Fetch Nitro $fetch', { exact: true }).click(); + + const transaction = await transactionPromise; + + expect(transaction.contexts.trace.op).toBe('http.server'); + + if (isDevMode) { + expect(transaction.environment).toBe('development'); + } else { + expect(transaction.environment).toBe('production'); + } + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.client.test.ts new file mode 100644 index 000000000000..2c6a1be53662 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.client.test.ts @@ -0,0 +1,151 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test.describe('client-side errors', async () => { + test('captures error thrown on click', async ({ page }) => { + const errorPromise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from nuxt-5 E2E test app'; + }); + + await page.goto(`/client-error`); + await page.locator('#errorBtn').click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual('/client-error'); + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from nuxt-5 E2E test app', + mechanism: { + handled: false, + type: 'auto.function.nuxt.vue-error', + }, + }, + ], + }, + }); + }); + + test('captures error thrown in NuxtErrorBoundary', async ({ page }) => { + const errorPromise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown in Error Boundary'; + }); + + await page.goto(`/client-error`); + await page.locator('#error-in-error-boundary').click(); + + const error = await errorPromise; + + const expectedBreadcrumb = { + category: 'console', + message: 'Additional functionality in NuxtErrorBoundary', + }; + + const matchingBreadcrumb = error.breadcrumbs.find( + (breadcrumb: { category: string; message: string }) => + breadcrumb.category === expectedBreadcrumb.category && breadcrumb.message === expectedBreadcrumb.message, + ); + + expect(matchingBreadcrumb).toBeTruthy(); + expect(matchingBreadcrumb?.category).toBe(expectedBreadcrumb.category); + expect(matchingBreadcrumb?.message).toBe(expectedBreadcrumb.message); + + expect(error.transaction).toEqual('/client-error'); + expect(error.sdk.name).toEqual('sentry.javascript.nuxt'); + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown in Error Boundary', + mechanism: { + handled: false, + type: 'auto.function.nuxt.vue-error', + }, + }, + ], + }, + }); + }); + + test('shows parametrized route on button error', async ({ page }) => { + const errorPromise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Param Route Button'; + }); + + await page.goto(`/test-param/1234`); + await page.locator('#errorBtn').click(); + + const error = await errorPromise; + + expect(error.sdk.name).toEqual('sentry.javascript.nuxt'); + expect(error.transaction).toEqual('/test-param/:param()'); + expect(error.request.url).toMatch(/\/test-param\/1234/); + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from Param Route Button', + mechanism: { + handled: false, + type: 'auto.function.nuxt.vue-error', + }, + }, + ], + }, + }); + }); + + test('page is still interactive after client error', async ({ page }) => { + const error1Promise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from nuxt-5 E2E test app'; + }); + + await page.goto(`/client-error`); + await page.locator('#errorBtn').click(); + + const error1 = await error1Promise; + + const error2Promise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Another Error thrown from nuxt-5 E2E test app'; + }); + + await page.locator('#errorBtn2').click(); + + const error2 = await error2Promise; + + expect(error1).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from nuxt-5 E2E test app', + mechanism: { + handled: false, + type: 'auto.function.nuxt.vue-error', + }, + }, + ], + }, + }); + + expect(error2).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Another Error thrown from nuxt-5 E2E test app', + mechanism: { + handled: false, + type: 'auto.function.nuxt.vue-error', + }, + }, + ], + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts new file mode 100644 index 000000000000..163dfd28c80a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test.describe('server-side errors', async () => { + test('captures api fetch error (fetched on click)', async ({ page }) => { + const errorPromise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 4 Server error'; + }); + + await page.goto(`/fetch-server-routes`); + await page.getByText('Fetch Server API Error', { exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual('GET /api/server-error'); + + const exception0 = error.exception.values[0]; + const exception1 = error.exception.values[1]; + + expect(exception0.type).toEqual('Error'); + expect(exception0.value).toEqual('Nuxt 4 Server error'); + expect(exception0.mechanism).toEqual({ + handled: false, + type: 'auto.function.nuxt.nitro', + exception_id: 1, + parent_id: 0, + source: 'cause', + }); + + expect(exception1.type).toEqual('HTTPError'); + expect(exception1.value).toEqual('Nuxt 4 Server error'); + // TODO: This isn't correct but requires adjustment in the core SDK + expect(exception1.mechanism).toEqual({ handled: true, type: 'generic', exception_id: 0 }); + }); + + test('captures api fetch error (fetched on click) with parametrized route', async ({ page }) => { + const errorPromise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 4 Param Server error'; + }); + + await page.goto(`/test-param/1234`); + await page.getByRole('button', { name: 'Fetch Server API Error', exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual('GET /api/param-error/1234'); + + const exception0 = error.exception.values[0]; + const exception1 = error.exception.values[1]; + + expect(exception0.type).toEqual('Error'); + expect(exception0.value).toEqual('Nuxt 4 Param Server error'); + expect(exception0.mechanism).toEqual({ + handled: false, + type: 'auto.function.nuxt.nitro', + exception_id: 1, + parent_id: 0, + source: 'cause', + }); + + expect(exception1.type).toEqual('HTTPError'); + expect(exception1.value).toEqual('Nuxt 4 Param Server error'); + // TODO: This isn't correct but requires adjustment in the core SDK + expect(exception1.mechanism).toEqual({ handled: true, type: 'generic', exception_id: 0 }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/isDevMode.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/isDevMode.ts new file mode 100644 index 000000000000..d2be94232110 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/isDevMode.ts @@ -0,0 +1 @@ +export const isDevMode = !!process.env.TEST_ENV && process.env.TEST_ENV.includes('development'); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts new file mode 100644 index 000000000000..3c314b80b59c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts @@ -0,0 +1,333 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction, waitForError } from '@sentry-internal/test-utils'; + +// TODO: Skipped for Nuxt 5 as the SDK is not yet updated for that +test.describe.skip('Server Middleware Instrumentation', () => { + test('should create separate spans for each server middleware', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + // Make request to the API endpoint that will trigger all server middleware + const response = await request.get('/api/middleware-test'); + expect(response.status()).toBe(200); + + const responseData = await response.json(); + expect(responseData.message).toBe('Server middleware test endpoint'); + + const serverTxnEvent = await serverTxnEventPromise; + + // Verify that we have spans for each middleware + const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware.nuxt') || []; + + // 3 simple + 3 hooks (onRequest+handler+onBeforeResponse) + 5 array hooks (2 onRequest + 1 handler + 2 onBeforeResponse + expect(middlewareSpans).toHaveLength(11); + + // Check for specific middleware spans + const firstMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '01.first'); + const secondMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '02.second'); + const authMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '03.auth'); + const hooksOnRequestSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '04.hooks'); + const arrayHooksHandlerSpan = middlewareSpans.find( + span => span.data?.['nuxt.middleware.name'] === '05.array-hooks', + ); + + expect(firstMiddlewareSpan).toBeDefined(); + expect(secondMiddlewareSpan).toBeDefined(); + expect(authMiddlewareSpan).toBeDefined(); + expect(hooksOnRequestSpan).toBeDefined(); + expect(arrayHooksHandlerSpan).toBeDefined(); + + // Verify each span has the correct attributes + [firstMiddlewareSpan, secondMiddlewareSpan, authMiddlewareSpan].forEach(span => { + expect(span).toEqual( + expect.objectContaining({ + op: 'middleware.nuxt', + data: expect.objectContaining({ + 'sentry.op': 'middleware.nuxt', + 'sentry.origin': 'auto.middleware.nuxt', + 'sentry.source': 'custom', + 'http.request.method': 'GET', + 'http.route': '/api/middleware-test', + }), + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }), + ); + }); + + // Verify spans have different span IDs (each middleware gets its own span) + const spanIds = middlewareSpans.map(span => span.span_id); + const uniqueSpanIds = new Set(spanIds); + // 3 simple + 3 hooks (onRequest+handler+onBeforeResponse) + 5 array hooks (2 onRequest + 1 handler + 2 onBeforeResponse) + expect(uniqueSpanIds.size).toBe(11); + + // Verify spans share the same trace ID + const traceIds = middlewareSpans.map(span => span.trace_id); + const uniqueTraceIds = new Set(traceIds); + expect(uniqueTraceIds.size).toBe(1); + }); + + test('middleware spans should have proper parent-child relationship', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + await request.get('/api/middleware-test'); + const serverTxnEvent = await serverTxnEventPromise; + + const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware.nuxt') || []; + + // All middleware spans should be children of the main transaction + middlewareSpans.forEach(span => { + expect(span.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); + }); + }); + + test('should capture errors thrown in middleware and associate them with the span', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + const errorEventPromise = waitForError('nuxt-5', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Auth middleware error'; + }); + + // Make request with query param to trigger error in auth middleware + const response = await request.get('/api/middleware-test?throwError=true'); + + // The request should fail due to the middleware error + expect(response.status()).toBe(500); + + const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); + + // Find the auth middleware span + const authMiddlewareSpan = serverTxnEvent.spans?.find( + span => span.op === 'middleware.nuxt' && span.data?.['nuxt.middleware.name'] === '03.auth', + ); + + expect(authMiddlewareSpan).toBeDefined(); + + // Verify the span has error status + expect(authMiddlewareSpan?.status).toBe('internal_error'); + + // Verify the error event is associated with the correct transaction + expect(errorEvent.transaction).toContain('GET /api/middleware-test'); + + // Verify the error has the correct mechanism + expect(errorEvent.exception?.values?.[0]).toEqual( + expect.objectContaining({ + value: 'Auth middleware error', + type: 'Error', + mechanism: expect.objectContaining({ + handled: false, + type: 'auto.middleware.nuxt', + }), + }), + ); + }); + + test('should create spans for onRequest and onBeforeResponse hooks', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + // Make request to trigger middleware with hooks + const response = await request.get('/api/middleware-test'); + expect(response.status()).toBe(200); + + const serverTxnEvent = await serverTxnEventPromise; + const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware.nuxt') || []; + + // Find spans for the hooks middleware + const hooksSpans = middlewareSpans.filter(span => span.data?.['nuxt.middleware.name'] === '04.hooks'); + + // Should have spans for onRequest, handler, and onBeforeResponse + expect(hooksSpans).toHaveLength(3); + + // Find specific hook spans + const onRequestSpan = hooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'onRequest'); + const handlerSpan = hooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'handler'); + const onBeforeResponseSpan = hooksSpans.find( + span => span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse', + ); + + expect(onRequestSpan).toBeDefined(); + expect(handlerSpan).toBeDefined(); + expect(onBeforeResponseSpan).toBeDefined(); + + // Verify span names include hook types + expect(onRequestSpan?.description).toBe('04.hooks.onRequest'); + expect(handlerSpan?.description).toBe('04.hooks'); + expect(onBeforeResponseSpan?.description).toBe('04.hooks.onBeforeResponse'); + + // Verify all spans have correct middleware name (without hook suffix) + [onRequestSpan, handlerSpan, onBeforeResponseSpan].forEach(span => { + expect(span?.data?.['nuxt.middleware.name']).toBe('04.hooks'); + }); + + // Verify hook-specific attributes + expect(onRequestSpan?.data?.['nuxt.middleware.hook.name']).toBe('onRequest'); + expect(handlerSpan?.data?.['nuxt.middleware.hook.name']).toBe('handler'); + expect(onBeforeResponseSpan?.data?.['nuxt.middleware.hook.name']).toBe('onBeforeResponse'); + + // Verify no index attributes for single hooks + expect(onRequestSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); + expect(handlerSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); + expect(onBeforeResponseSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); + }); + + test('should create spans with index attributes for array hooks', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + // Make request to trigger middleware with array hooks + const response = await request.get('/api/middleware-test'); + expect(response.status()).toBe(200); + + const serverTxnEvent = await serverTxnEventPromise; + const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware.nuxt') || []; + + // Find spans for the array hooks middleware + const arrayHooksSpans = middlewareSpans.filter(span => span.data?.['nuxt.middleware.name'] === '05.array-hooks'); + + // Should have spans for 2 onRequest + 1 handler + 2 onBeforeResponse = 5 spans + expect(arrayHooksSpans).toHaveLength(5); + + // Find onRequest array spans + const onRequestSpans = arrayHooksSpans.filter(span => span.data?.['nuxt.middleware.hook.name'] === 'onRequest'); + expect(onRequestSpans).toHaveLength(2); + + // Find onBeforeResponse array spans + const onBeforeResponseSpans = arrayHooksSpans.filter( + span => span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse', + ); + expect(onBeforeResponseSpans).toHaveLength(2); + + // Find handler span + const handlerSpan = arrayHooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'handler'); + expect(handlerSpan).toBeDefined(); + + // Verify index attributes for onRequest array + const onRequest0Span = onRequestSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 0); + const onRequest1Span = onRequestSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 1); + + expect(onRequest0Span).toBeDefined(); + expect(onRequest1Span).toBeDefined(); + + // Verify index attributes for onBeforeResponse array + const onBeforeResponse0Span = onBeforeResponseSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 0); + const onBeforeResponse1Span = onBeforeResponseSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 1); + + expect(onBeforeResponse0Span).toBeDefined(); + expect(onBeforeResponse1Span).toBeDefined(); + + // Verify span names for array handlers + expect(onRequest0Span?.description).toBe('05.array-hooks.onRequest'); + expect(onRequest1Span?.description).toBe('05.array-hooks.onRequest'); + expect(onBeforeResponse0Span?.description).toBe('05.array-hooks.onBeforeResponse'); + expect(onBeforeResponse1Span?.description).toBe('05.array-hooks.onBeforeResponse'); + + // Verify handler has no index + expect(handlerSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); + }); + + test('should handle errors in onRequest hooks', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + const errorEventPromise = waitForError('nuxt-5', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'OnRequest hook error'; + }); + + // Make request with query param to trigger error in onRequest + const response = await request.get('/api/middleware-test?throwOnRequestError=true'); + expect(response.status()).toBe(500); + + const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); + + // Find the onRequest span that should have error status + const onRequestSpan = serverTxnEvent.spans?.find( + span => + span.op === 'middleware.nuxt' && + span.data?.['nuxt.middleware.name'] === '04.hooks' && + span.data?.['nuxt.middleware.hook.name'] === 'onRequest', + ); + + expect(onRequestSpan).toBeDefined(); + expect(onRequestSpan?.status).toBe('internal_error'); + expect(errorEvent.exception?.values?.[0]?.value).toBe('OnRequest hook error'); + }); + + test('should handle errors in onBeforeResponse hooks', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + const errorEventPromise = waitForError('nuxt-5', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'OnBeforeResponse hook error'; + }); + + // Make request with query param to trigger error in onBeforeResponse + const response = await request.get('/api/middleware-test?throwOnBeforeResponseError=true'); + expect(response.status()).toBe(500); + + const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); + + // Find the onBeforeResponse span that should have error status + const onBeforeResponseSpan = serverTxnEvent.spans?.find( + span => + span.op === 'middleware.nuxt' && + span.data?.['nuxt.middleware.name'] === '04.hooks' && + span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse', + ); + + expect(onBeforeResponseSpan).toBeDefined(); + expect(onBeforeResponseSpan?.status).toBe('internal_error'); + expect(errorEvent.exception?.values?.[0]?.value).toBe('OnBeforeResponse hook error'); + }); + + test('should handle errors in array hooks with proper index attribution', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + const errorEventPromise = waitForError('nuxt-5', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'OnRequest[1] hook error'; + }); + + // Make request with query param to trigger error in second onRequest handler + const response = await request.get('/api/middleware-test?throwOnRequest1Error=true'); + expect(response.status()).toBe(500); + + const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); + + // Find the second onRequest span that should have error status + const onRequest1Span = serverTxnEvent.spans?.find( + span => + span.op === 'middleware.nuxt' && + span.data?.['nuxt.middleware.name'] === '05.array-hooks' && + span.data?.['nuxt.middleware.hook.name'] === 'onRequest' && + span.data?.['nuxt.middleware.hook.index'] === 1, + ); + + expect(onRequest1Span).toBeDefined(); + expect(onRequest1Span?.status).toBe('internal_error'); + expect(errorEvent.exception?.values?.[0]?.value).toBe('OnRequest[1] hook error'); + + // Verify the first onRequest handler still executed successfully + const onRequest0Span = serverTxnEvent.spans?.find( + span => + span.op === 'middleware.nuxt' && + span.data?.['nuxt.middleware.name'] === '05.array-hooks' && + span.data?.['nuxt.middleware.hook.name'] === 'onRequest' && + span.data?.['nuxt.middleware.hook.index'] === 0, + ); + + expect(onRequest0Span).toBeDefined(); + expect(onRequest0Span?.status).not.toBe('internal_error'); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/pinia.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/pinia.test.ts new file mode 100644 index 000000000000..fa1529187286 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/pinia.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +// TODO: Pinia does not yet support Nuxt 5, so this test is skipped for now. +test.skip('sends pinia action breadcrumbs and state context', async ({ page }) => { + await page.goto('/pinia-cart'); + + await page.locator('#item-input').fill('item'); + await page.locator('#item-add').click(); + + const errorPromise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0].value === 'This is an error'; + }); + + await page.locator('#throw-error').click(); + + const error = await errorPromise; + + expect(error).toBeTruthy(); + expect(error.breadcrumbs?.length).toBeGreaterThan(0); + + const actionBreadcrumb = error.breadcrumbs?.find(breadcrumb => breadcrumb.category === 'pinia.action'); + + expect(actionBreadcrumb).toBeDefined(); + expect(actionBreadcrumb?.message).toBe('Store: cart | Action: addItem.transformed'); + expect(actionBreadcrumb?.level).toBe('info'); + + const stateContext = error.contexts?.state?.state; + + expect(stateContext).toBeDefined(); + expect(stateContext?.type).toBe('pinia'); + expect(stateContext?.value).toEqual({ + transformed: true, + cart: { rawItems: ['item'] }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage-aliases.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage-aliases.test.ts new file mode 100644 index 000000000000..c6ff331a2780 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage-aliases.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Storage Instrumentation - Aliases', () => { + const prefixKey = (key: string) => `test-storage:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments storage alias methods (get, set, has, del, remove) and creates spans', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/storage-aliases-test') ?? false; + }); + + const response = await request.get('/api/storage-aliases-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test set (alias for setItem) + const setSpans = findSpansByOp('cache.set_item'); + expect(setSpans.length).toBeGreaterThanOrEqual(1); + const setSpan = setSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(setSpan).toBeDefined(); + expect(setSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + 'db.operation.name': 'setItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(setSpan?.description).toBe(prefixKey('alias:user')); + + // Test get (alias for getItem) + const getSpans = findSpansByOp('cache.get_item'); + expect(getSpans.length).toBeGreaterThanOrEqual(1); + const getSpan = getSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(getSpan).toBeDefined(); + expect(getSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(getSpan?.description).toBe(prefixKey('alias:user')); + + // Test has (alias for hasItem) + const hasSpans = findSpansByOp('cache.has_item'); + expect(hasSpans.length).toBeGreaterThanOrEqual(1); + const hasSpan = hasSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(hasSpan).toBeDefined(); + expect(hasSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'hasItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test del and remove (both aliases for removeItem) + const removeSpans = findSpansByOp('cache.remove_item'); + expect(removeSpans.length).toBeGreaterThanOrEqual(2); // Should have both del and remove calls + + const delSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp1')); + expect(delSpan).toBeDefined(); + expect(delSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp1'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(delSpan?.description).toBe(prefixKey('alias:temp1')); + + const removeSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp2')); + expect(removeSpan).toBeDefined(); + expect(removeSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp2'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(removeSpan?.description).toBe(prefixKey('alias:temp2')); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage.test.ts new file mode 100644 index 000000000000..b0d9af9142da --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage.test.ts @@ -0,0 +1,151 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Storage Instrumentation', () => { + const prefixKey = (key: string) => `test-storage:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments all storage operations and creates spans with correct attributes', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/storage-test') ?? false; + }); + + const response = await request.get('/api/storage-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test setItem spans + const setItemSpans = findSpansByOp('cache.set_item'); + expect(setItemSpans.length).toBeGreaterThanOrEqual(1); + const setItemSpan = setItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(setItemSpan).toBeDefined(); + expect(setItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + 'db.operation.name': 'setItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(setItemSpan?.description).toBe(prefixKey('user:123')); + + // Test setItemRaw spans + const setItemRawSpans = findSpansByOp('cache.set_item_raw'); + expect(setItemRawSpans.length).toBeGreaterThanOrEqual(1); + const setItemRawSpan = setItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + expect(setItemRawSpan).toBeDefined(); + expect(setItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item_raw', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + 'db.operation.name': 'setItemRaw', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test hasItem spans - should have cache hit attribute + const hasItemSpans = findSpansByOp('cache.has_item'); + expect(hasItemSpans.length).toBeGreaterThanOrEqual(1); + const hasItemSpan = hasItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(hasItemSpan).toBeDefined(); + expect(hasItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'hasItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test getItem spans - should have cache hit attribute + const getItemSpans = findSpansByOp('cache.get_item'); + expect(getItemSpans.length).toBeGreaterThanOrEqual(1); + const getItemSpan = getItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(getItemSpan).toBeDefined(); + expect(getItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(getItemSpan?.description).toBe(prefixKey('user:123')); + + // Test getItemRaw spans - should have cache hit attribute + const getItemRawSpans = findSpansByOp('cache.get_item_raw'); + expect(getItemRawSpans.length).toBeGreaterThanOrEqual(1); + const getItemRawSpan = getItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + expect(getItemRawSpan).toBeDefined(); + expect(getItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item_raw', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItemRaw', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test getKeys spans + const getKeysSpans = findSpansByOp('cache.get_keys'); + expect(getKeysSpans.length).toBeGreaterThanOrEqual(1); + expect(getKeysSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_keys', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'getKeys', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test removeItem spans + const removeItemSpans = findSpansByOp('cache.remove_item'); + expect(removeItemSpans.length).toBeGreaterThanOrEqual(1); + const removeItemSpan = removeItemSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('batch:1'), + ); + expect(removeItemSpan).toBeDefined(); + expect(removeItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('batch:1'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test clear spans + const clearSpans = findSpansByOp('cache.clear'); + expect(clearSpans.length).toBeGreaterThanOrEqual(1); + expect(clearSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.clear', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'clear', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.cached-html.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.cached-html.test.ts new file mode 100644 index 000000000000..7c7d51af4d4f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.cached-html.test.ts @@ -0,0 +1,208 @@ +import { expect, test, type Page } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('Rendering Modes with Cached HTML', () => { + test('changes tracing meta tags with multiple requests on Client-Side only page', async ({ page }) => { + await testChangingTracingMetaTagsOnISRPage(page, '/rendering-modes/client-side-only-page', 'Client Side Only Page'); + }); + + test('changes tracing meta tags with multiple requests on ISR-cached page', async ({ page }) => { + await testChangingTracingMetaTagsOnISRPage(page, '/rendering-modes/isr-cached-page', 'ISR Cached Page'); + }); + + test('changes tracing meta tags with multiple requests on 1h ISR-cached page', async ({ page }) => { + await testChangingTracingMetaTagsOnISRPage(page, '/rendering-modes/isr-1h-cached-page', 'ISR 1h Cached Page'); + }); + + // TODO: Make test work with Nuxt 5 + test.skip('exclude tracing meta tags on SWR-cached page', async ({ page }) => { + await testExcludeTracingMetaTagsOnCachedPage(page, '/rendering-modes/swr-cached-page', 'SWR Cached Page'); + }); + + // TODO: Make test work with Nuxt 5 + test.skip('exclude tracing meta tags on SWR 1h cached page', async ({ page }) => { + await testExcludeTracingMetaTagsOnCachedPage(page, '/rendering-modes/swr-1h-cached-page', 'SWR 1h Cached Page'); + }); + + test('exclude tracing meta tags on pre-rendered page', async ({ page }) => { + await testExcludeTracingMetaTagsOnCachedPage(page, '/rendering-modes/pre-rendered-page', 'Pre-Rendered Page'); + }); +}); + +/** + * Tests that tracing meta-tags change with multiple requests on ISR-cached pages + * This utility handles the common pattern of: + * 1. Making two requests to an ISR-cached page + * 2. Verifying tracing meta-tags are present and change between requests + * 3. Verifying distributed tracing works correctly for both requests + * 4. Verifying trace IDs are different between requests + * + * @param page - Playwright page object + * @param routePath - The route path to test (e.g., '/rendering-modes/isr-cached-page') + * @param expectedPageText - The text to verify is visible on the page (e.g., 'ISR Cached Page') + */ +export async function testChangingTracingMetaTagsOnISRPage( + page: Page, + routePath: string, + expectedPageText: string, +): Promise { + // === 1. Request === + const clientTxnEventPromise1 = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction === routePath; + }); + + const serverTxnEventPromise1 = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; + }); + + const [_1, clientTxnEvent1, serverTxnEvent1] = await Promise.all([ + page.goto(routePath), + clientTxnEventPromise1, + serverTxnEventPromise1, + expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), + ]); + + const baggageMetaTagContent1 = await page.locator('meta[name="baggage"]').getAttribute('content'); + const sentryTraceMetaTagContent1 = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); + const [htmlMetaTraceId1] = sentryTraceMetaTagContent1?.split('-') || []; + + // === 2. Request === + + const clientTxnEventPromise2 = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction === routePath; + }); + + const serverTxnEventPromise2 = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; + }); + + const [_2, clientTxnEvent2, serverTxnEvent2] = await Promise.all([ + page.goto(routePath), + clientTxnEventPromise2, + serverTxnEventPromise2, + expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), + ]); + + const baggageMetaTagContent2 = await page.locator('meta[name="baggage"]').getAttribute('content'); + const sentryTraceMetaTagContent2 = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); + const [htmlMetaTraceId2] = sentryTraceMetaTagContent2?.split('-') || []; + + const serverTxnEvent1TraceId = serverTxnEvent1.contexts?.trace?.trace_id; + const serverTxnEvent2TraceId = serverTxnEvent2.contexts?.trace?.trace_id; + + await test.step('Test distributed trace from 1. request', () => { + expect(baggageMetaTagContent1).toContain(`sentry-trace_id=${serverTxnEvent1TraceId}`); + + expect(clientTxnEvent1.contexts?.trace?.trace_id).toBe(serverTxnEvent1TraceId); + expect(clientTxnEvent1.contexts?.trace?.parent_span_id).toBe(serverTxnEvent1.contexts?.trace?.span_id); + expect(serverTxnEvent1.contexts?.trace?.trace_id).toBe(htmlMetaTraceId1); + }); + + await test.step('Test distributed trace from 2. request', () => { + expect(baggageMetaTagContent2).toContain(`sentry-trace_id=${serverTxnEvent2TraceId}`); + + expect(clientTxnEvent2.contexts?.trace?.trace_id).toBe(serverTxnEvent2TraceId); + expect(clientTxnEvent2.contexts?.trace?.parent_span_id).toBe(serverTxnEvent2.contexts?.trace?.span_id); + expect(serverTxnEvent2.contexts?.trace?.trace_id).toBe(htmlMetaTraceId2); + }); + + await test.step('Test that trace IDs from subsequent requests are different', () => { + // Different trace IDs for the server transactions + expect(serverTxnEvent1TraceId).toBeDefined(); + expect(serverTxnEvent1TraceId).not.toBe(serverTxnEvent2TraceId); + expect(serverTxnEvent1TraceId).not.toBe(htmlMetaTraceId2); + }); +} + +/** + * Tests that tracing meta-tags are excluded on cached pages (SWR, pre-rendered, etc.) + * This utility handles the common pattern of: + * 1. Making two requests to a cached page + * 2. Verifying no tracing meta-tags are present + * 3. Verifying only the first request creates a server transaction + * 4. Verifying traces are not distributed + * + * @param page - Playwright page object + * @param routePath - The route path to test (e.g., '/rendering-modes/swr-cached-page') + * @param expectedPageText - The text to verify is visible on the page (e.g., 'SWR Cached Page') + * @returns Object containing transaction events for additional custom assertions + */ +export async function testExcludeTracingMetaTagsOnCachedPage( + page: Page, + routePath: string, + expectedPageText: string, +): Promise { + // === 1. Request === + const clientTxnEventPromise1 = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction === routePath; + }); + + // Only the 1. request creates a server transaction + const serverTxnEventPromise1 = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; + }); + + const [_1, clientTxnEvent1, serverTxnEvent1] = await Promise.all([ + page.goto(routePath), + clientTxnEventPromise1, + serverTxnEventPromise1, + expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), + ]); + + // Verify no baggage and sentry-trace meta-tags are present on first request + expect(await page.locator('meta[name="baggage"]').count()).toBe(0); + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); + + // === 2. Request === + + await page.goto(routePath); + + const clientTxnEventPromise2 = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction === routePath; + }); + + let serverTxnEvent2 = undefined; + const serverTxnEventPromise2 = Promise.race([ + waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; + }), + new Promise((_, reject) => setTimeout(() => reject(new Error('No second server transaction expected')), 2000)), + ]); + + try { + serverTxnEvent2 = await serverTxnEventPromise2; + throw new Error('Second server transaction should not have been sent'); + } catch (error) { + expect(error.message).toBe('No second server transaction expected'); + } + + const [clientTxnEvent2] = await Promise.all([ + clientTxnEventPromise2, + expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), + ]); + + const clientTxnEvent1TraceId = clientTxnEvent1.contexts?.trace?.trace_id; + const clientTxnEvent2TraceId = clientTxnEvent2.contexts?.trace?.trace_id; + + const serverTxnEvent1TraceId = serverTxnEvent1.contexts?.trace?.trace_id; + const serverTxnEvent2TraceId = serverTxnEvent2?.contexts?.trace?.trace_id; + + await test.step('No baggage and sentry-trace meta-tags are present on second request', async () => { + expect(await page.locator('meta[name="baggage"]').count()).toBe(0); + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); + }); + + await test.step('1. Server Transaction and all Client Transactions are defined', () => { + expect(serverTxnEvent1TraceId).toBeDefined(); + expect(clientTxnEvent1TraceId).toBeDefined(); + expect(clientTxnEvent2TraceId).toBeDefined(); + expect(serverTxnEvent2).toBeUndefined(); + expect(serverTxnEvent2TraceId).toBeUndefined(); + }); + + await test.step('Trace is not distributed', () => { + // Cannot create distributed trace as HTML Meta Tags are not added (caching leads to multiple usages of the same server trace id) + expect(clientTxnEvent1TraceId).not.toBe(clientTxnEvent2TraceId); + expect(clientTxnEvent1TraceId).not.toBe(serverTxnEvent1TraceId); + }); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.client.test.ts new file mode 100644 index 000000000000..d4d4b141fa16 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.client.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import type { Span } from '@sentry/nuxt'; + +test('sends a pageload root span with a parameterized URL', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-5', async transactionEvent => { + return transactionEvent.transaction === '/test-param/:param()'; + }); + + await page.goto(`/test-param/1234`); + + const rootSpan = await transactionPromise; + + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.pageload.vue', + 'sentry.op': 'pageload', + 'params.param': '1234', + }, + op: 'pageload', + origin: 'auto.pageload.vue', + }, + }, + transaction: '/test-param/:param()', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends component tracking spans when `trackComponents` is enabled', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-5', async transactionEvent => { + return transactionEvent.transaction === '/client-error'; + }); + + await page.goto(`/client-error`); + + const rootSpan = await transactionPromise; + const errorButtonSpan = rootSpan.spans.find((span: Span) => span.description === 'Vue '); + + const expected = { + data: { 'sentry.origin': 'auto.ui.vue', 'sentry.op': 'ui.vue.mount' }, + description: 'Vue ', + op: 'ui.vue.mount', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.ui.vue', + }; + + expect(errorButtonSpan).toMatchObject(expected); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.server.test.ts new file mode 100644 index 000000000000..ebd367d96031 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.server.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test('sends a server action transaction on pageload', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction.includes('GET /test-param/'); + }); + + await page.goto('/test-param/1234'); + + const transaction = await transactionPromise; + + expect(transaction.contexts.trace).toEqual( + expect.objectContaining({ + data: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.http', + }), + }), + ); +}); + +test('does not send transactions for build asset folder "_nuxt"', async ({ page }) => { + let buildAssetFolderOccurred = false; + + waitForTransaction('nuxt-5', transactionEvent => { + if (transactionEvent.transaction?.match(/^GET \/_nuxt\//)) { + buildAssetFolderOccurred = true; + } + return false; // expects to return a boolean (but not relevant here) + }); + + const transactionEventPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction.includes('GET /test-param/'); + }); + + await page.goto('/test-param/1234'); + + const transactionEvent = await transactionEventPromise; + + expect(buildAssetFolderOccurred).toBe(false); + + expect(transactionEvent.transaction).toBe('GET /test-param/:param()'); +}); + +// TODO: Make test work with Nuxt 5 +test.skip('captures server API calls made with Nitro $fetch', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-5', async transactionEvent => { + return transactionEvent.transaction === 'GET /api/nitro-fetch'; + }); + + await page.goto(`/fetch-server-routes`); + await page.getByText('Fetch Nitro $fetch', { exact: true }).click(); + + const httpServerFetchSpan = await transactionPromise; + const httpClientSpan = httpServerFetchSpan.spans.find(span => span.description === 'GET https://example.com/'); + + expect(httpServerFetchSpan.transaction).toEqual('GET /api/nitro-fetch'); + expect(httpServerFetchSpan.contexts.trace.op).toEqual('http.server'); + + expect(httpClientSpan.parent_span_id).toEqual(httpServerFetchSpan.contexts.trace.span_id); + expect(httpClientSpan.op).toEqual('http.client'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.test.ts new file mode 100644 index 000000000000..e136d5635a29 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.test.ts @@ -0,0 +1,158 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('distributed tracing', () => { + const PARAM = 's0me-param'; + + test('capture a distributed pageload trace', async ({ page }) => { + const clientTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction === '/test-param/:param()'; + }); + + const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /test-param/') ?? false; + }); + + const [_, clientTxnEvent, serverTxnEvent] = await Promise.all([ + page.goto(`/test-param/${PARAM}`), + clientTxnEventPromise, + serverTxnEventPromise, + expect(page.getByText(`Param: ${PARAM}`)).toBeVisible(), + ]); + + const baggageMetaTagContent = await page.locator('meta[name="baggage"]').getAttribute('content'); + + // URL-encoded for parametrized 'GET /test-param/s0me-param' -> `GET /test-param/:param` + expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F%3Aparam`); + expect(baggageMetaTagContent).toContain(`sentry-trace_id=${serverTxnEvent.contexts?.trace?.trace_id}`); + expect(baggageMetaTagContent).toContain('sentry-sampled=true'); + expect(baggageMetaTagContent).toContain('sentry-sample_rate=1'); + + const sentryTraceMetaTagContent = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); + const [metaTraceId, metaParentSpanId, metaSampled] = sentryTraceMetaTagContent?.split('-') || []; + + expect(metaSampled).toBe('1'); + + expect(clientTxnEvent).toMatchObject({ + transaction: '/test-param/:param()', + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.vue', + trace_id: metaTraceId, + parent_span_id: metaParentSpanId, + }, + }, + }); + + expect(serverTxnEvent).toMatchObject({ + transaction: `GET /test-param/:param()`, // parametrized route + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.otel.http', + }, + }, + }); + + // connected trace + expect(clientTxnEvent.contexts?.trace?.trace_id).toBeDefined(); + expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBeDefined(); + + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); + expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); + expect(serverTxnEvent.contexts?.trace?.trace_id).toBe(metaTraceId); + }); + + // TODO: Make test work with Nuxt 5 + test.skip('capture a distributed trace from a client-side API request with parametrized routes', async ({ page }) => { + const clientTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction === '/test-param/user/:userId()'; + }); + const ssrTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /test-param/user') ?? false; + }); + const serverReqTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /api/user/') ?? false; + }); + + // Navigate to the page which will trigger an API call from the client-side + await page.goto(`/test-param/user/${PARAM}`); + + const [clientTxnEvent, ssrTxnEvent, serverReqTxnEvent] = await Promise.all([ + clientTxnEventPromise, + ssrTxnEventPromise, + serverReqTxnEventPromise, + ]); + + const httpClientSpan = clientTxnEvent?.spans?.find(span => span.description === `GET /api/user/${PARAM}`); + + expect(clientTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: '/test-param/user/:userId()', // parametrized route + transaction_info: { source: 'route' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'pageload', + origin: 'auto.pageload.vue', + }), + }), + }), + ); + + expect(httpClientSpan).toBeDefined(); + expect(httpClientSpan).toEqual( + expect.objectContaining({ + description: `GET /api/user/${PARAM}`, // fixme: parametrize + parent_span_id: clientTxnEvent.contexts?.trace?.span_id, // pageload span is parent + data: expect.objectContaining({ + url: `/api/user/${PARAM}`, + type: 'fetch', + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + 'http.method': 'GET', + }), + }), + ); + + expect(ssrTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: `GET /test-param/user/:userId()`, // parametrized route + transaction_info: { source: 'route' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.otel.http', + }), + }), + }), + ); + + expect(serverReqTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: `GET /api/user/:userId`, // parametrized route + transaction_info: { source: 'route' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.otel.http', + parent_span_id: httpClientSpan?.span_id, // http.client span is parent + }), + }), + }), + ); + + // All 3 transactions and the http.client span should share the same trace_id + expect(clientTxnEvent.contexts?.trace?.trace_id).toBeDefined(); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(httpClientSpan?.trace_id); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(ssrTxnEvent.contexts?.trace?.trace_id); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverReqTxnEvent.contexts?.trace?.trace_id); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tsconfig.json b/dev-packages/e2e-tests/test-applications/nuxt-5/tsconfig.json new file mode 100644 index 000000000000..a746f2a70c28 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tsconfig.json @@ -0,0 +1,4 @@ +{ + // https://nuxt.com/docs/guide/concepts/typescript + "extends": "./.nuxt/tsconfig.json" +} diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index 1ec523450789..322dc969ff0d 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -45,8 +45,8 @@ "access": "public" }, "peerDependencies": { - "nuxt": ">=3.7.0 || 4.x", - "nitro": "3.x" + "nuxt": ">=3.7.0 || 4.x || 5.x", + "nitro": "2.x || 3.x" }, "peerDependenciesMeta": { "nitro": { @@ -62,7 +62,8 @@ "@sentry/node-core": "10.45.0", "@sentry/rollup-plugin": "^5.1.1", "@sentry/vite-plugin": "^5.1.0", - "@sentry/vue": "10.45.0" + "@sentry/vue": "10.45.0", + "local-pkg": "^1.1.2" }, "devDependencies": { "@nuxt/module-builder": "^0.8.4", diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 490fde751473..0c1e43031742 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -15,7 +15,7 @@ import { addDatabaseInstrumentation } from './vite/databaseConfig'; import { addMiddlewareImports, addMiddlewareInstrumentation } from './vite/middlewareConfig'; import { setupSourceMaps } from './vite/sourceMaps'; import { addStorageInstrumentation } from './vite/storageConfig'; -import { addOTelCommonJSImportAlias, findDefaultSdkInitFile } from './vite/utils'; +import { addOTelCommonJSImportAlias, findDefaultSdkInitFile, getNitroMajorVersion } from './vite/utils'; export type ModuleOptions = SentryNuxtModuleOptions; @@ -28,7 +28,7 @@ export default defineNuxtModule({ }, }, defaults: {}, - setup(moduleOptionsParam, nuxt) { + async setup(moduleOptionsParam, nuxt) { if (moduleOptionsParam?.enabled === false) { return; } @@ -78,22 +78,33 @@ export default defineNuxtModule({ } const serverConfigFile = findDefaultSdkInitFile('server', nuxt); + const isNitroV3 = (await getNitroMajorVersion()) >= 3; if (serverConfigFile) { - addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/handler-legacy.server')); + if (isNitroV3) { + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/handler.server')); + } else { + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/handler-legacy.server')); + } + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/sentry.server')); addPlugin({ src: moduleDirResolver.resolve('./runtime/plugins/route-detector.server'), mode: 'server', }); + + // Preps the middleware instrumentation module. + addMiddlewareImports(); + addStorageInstrumentation(nuxt, !isNitroV3); + addDatabaseInstrumentation(nuxt.options.nitro, !isNitroV3, moduleOptions); } if (clientConfigFile || serverConfigFile) { setupSourceMaps(moduleOptions, nuxt, addVitePlugin); } - addOTelCommonJSImportAlias(nuxt); + addOTelCommonJSImportAlias(nuxt, isNitroV3); const pagesDataTemplate = addTemplate({ filename: 'sentry--nuxt-pages-data.mjs', @@ -115,13 +126,6 @@ export default defineNuxtModule({ }; }); - // Preps the the middleware instrumentation module. - if (serverConfigFile) { - addMiddlewareImports(); - addStorageInstrumentation(nuxt); - addDatabaseInstrumentation(nuxt.options.nitro, moduleOptions); - } - // Add the sentry config file to the include array nuxt.hook('prepare:types', options => { const tsConfig = options.tsConfig as { include?: string[] }; @@ -147,7 +151,7 @@ export default defineNuxtModule({ return; } - if (serverConfigFile) { + if (serverConfigFile && !isNitroV3) { addMiddlewareInstrumentation(nitro); } diff --git a/packages/nuxt/src/runtime/plugins/sentry.server.ts b/packages/nuxt/src/runtime/plugins/sentry.server.ts index e3bf7854e673..7ea91e36cf25 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.server.ts @@ -13,11 +13,18 @@ export default (nitroApp => { // @ts-expect-error - 'render:html' is a valid hook name in the Nuxt context nitroApp.hooks.hook('render:html', (html: NuxtRenderHTMLContext, { event }: { event: H3Event }) => { - const headers = event.node.res?.getHeaders() || {}; + // h3 v1 (Nuxt 4): event.node.res.getHeaders(); h3 v2 (Nuxt 5): event.node is undefined + const nodeResHeadersH3v1 = event.node?.res?.getHeaders() || {}; - const isPreRenderedPage = Object.keys(headers).includes('x-nitro-prerender'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const isSWRCachedPage = event?.context?.cache?.options.swr as boolean | undefined; + // h3 v2 (Nuxt 5): response headers are on event.res.headers + const isPreRenderedPage = + Object.keys(nodeResHeadersH3v1).includes('x-nitro-prerender') || + // fix × typescript-eslint(no-unsafe-member-access): Unsafe member access .res on an `any` value. + // oxlint-disable-next-line typescript/no-explicit-any,typescript-oxlint/no-unsafe-member-access + !!(event as any).res?.headers?.has?.('x-nitro-prerender'); + + // oxlint-disable-next-line typescript-oxlint/no-unsafe-member-access + const isSWRCachedPage = event?.context?.cache?.options?.swr as boolean | undefined; if (!isPreRenderedPage && !isSWRCachedPage) { addSentryTracingMetaTags(html.head); diff --git a/packages/nuxt/src/vite/databaseConfig.ts b/packages/nuxt/src/vite/databaseConfig.ts index 5b8bf008421d..b025157339b3 100644 --- a/packages/nuxt/src/vite/databaseConfig.ts +++ b/packages/nuxt/src/vite/databaseConfig.ts @@ -7,7 +7,11 @@ import { addServerTemplate } from '../vendor/server-template'; /** * Sets up the database instrumentation. */ -export function addDatabaseInstrumentation(nitro: NitroConfig, moduleOptions?: SentryNuxtModuleOptions): void { +export function addDatabaseInstrumentation( + nitro: NitroConfig, + isLegacyNitro: boolean, + moduleOptions?: SentryNuxtModuleOptions, +): void { if (!nitro.experimental?.database) { // We cannot use DEBUG_BUILD here because it is a runtime flag, so it is not available for build time scripts // So we have to pass in the module options to the build time script @@ -38,5 +42,9 @@ export function addDatabaseInstrumentation(nitro: NitroConfig, moduleOptions?: S }, }); - addServerPlugin(createResolver(import.meta.url).resolve('./runtime/plugins/database-legacy.server')); + if (isLegacyNitro) { + addServerPlugin(createResolver(import.meta.url).resolve('./runtime/plugins/database-legacy.server')); + } else { + addServerPlugin(createResolver(import.meta.url).resolve('./runtime/plugins/database.server')); + } } diff --git a/packages/nuxt/src/vite/storageConfig.ts b/packages/nuxt/src/vite/storageConfig.ts index 87f843f05796..393f8b12e59b 100644 --- a/packages/nuxt/src/vite/storageConfig.ts +++ b/packages/nuxt/src/vite/storageConfig.ts @@ -5,7 +5,7 @@ import { addServerTemplate } from '../vendor/server-template'; /** * Prepares the storage config export to be used in the runtime storage instrumentation. */ -export function addStorageInstrumentation(nuxt: Nuxt): void { +export function addStorageInstrumentation(nuxt: Nuxt, isLegacyNitro: boolean): void { const moduleDirResolver = createResolver(import.meta.url); const userStorageMounts = Object.keys(nuxt.options.nitro.storage || {}); @@ -17,5 +17,9 @@ export function addStorageInstrumentation(nuxt: Nuxt): void { }, }); - addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/storage-legacy.server')); + if (isLegacyNitro) { + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/storage-legacy.server')); + } else { + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/storage.server')); + } } diff --git a/packages/nuxt/src/vite/utils.ts b/packages/nuxt/src/vite/utils.ts index 6b1092a952bc..86eafaae2c9b 100644 --- a/packages/nuxt/src/vite/utils.ts +++ b/packages/nuxt/src/vite/utils.ts @@ -3,6 +3,24 @@ import { consoleSandbox } from '@sentry/core'; import * as fs from 'fs'; import * as path from 'path'; +/** + * Gets the major version of the installed nitro package. + * Returns 2 as the default if nitro is not found or the version cannot be determined. + */ +export async function getNitroMajorVersion(): Promise { + try { + const { getPackageInfo } = await import('local-pkg'); + const info = await getPackageInfo('nitro'); + if (info?.version) { + const major = parseInt(info.version.split('.')[0] ?? '2', 10); + return isNaN(major) ? 2 : major; + } + } catch { + // If local-pkg is unavailable or nitro is not found, default to v2 + } + return 2; +} + /** * Find the default SDK init file for the given type (client or server). * The sentry.server.config file is prioritized over the instrument.server file. @@ -190,8 +208,8 @@ export function constructFunctionReExport(pathWithQuery: string, entryId: string * * @see https://nuxt.com/docs/guide/concepts/esm#aliasing-libraries */ -export function addOTelCommonJSImportAlias(nuxt: Nuxt): void { - if (!nuxt.options.dev) { +export function addOTelCommonJSImportAlias(nuxt: Nuxt, isNitroV3 = false): void { + if (!nuxt.options.dev || isNitroV3) { return; } diff --git a/packages/nuxt/test/vite/databaseConfig.test.ts b/packages/nuxt/test/vite/databaseConfig.test.ts index 4d95fc7a4df0..e987ab3984a5 100644 --- a/packages/nuxt/test/vite/databaseConfig.test.ts +++ b/packages/nuxt/test/vite/databaseConfig.test.ts @@ -34,7 +34,7 @@ describe('addDatabaseInstrumentation', () => { const nitroConfig: NitroConfig = {}; const moduleOptions: SentryNuxtModuleOptions = { debug: true }; - addDatabaseInstrumentation(nitroConfig, moduleOptions); + addDatabaseInstrumentation(nitroConfig, false, moduleOptions); expect(consoleLogSpy).toHaveBeenCalledWith( '[Sentry] [Nitro Database Plugin]: No database configuration found. Skipping database instrumentation.', @@ -45,7 +45,7 @@ describe('addDatabaseInstrumentation', () => { const nitroConfig: NitroConfig = {}; const moduleOptions: SentryNuxtModuleOptions = { debug: false }; - addDatabaseInstrumentation(nitroConfig, moduleOptions); + addDatabaseInstrumentation(nitroConfig, false, moduleOptions); expect(consoleLogSpy).not.toHaveBeenCalled(); }); @@ -53,7 +53,7 @@ describe('addDatabaseInstrumentation', () => { it('should not log debug message when moduleOptions is undefined', () => { const nitroConfig: NitroConfig = {}; - addDatabaseInstrumentation(nitroConfig, undefined); + addDatabaseInstrumentation(nitroConfig, false, undefined); expect(consoleLogSpy).not.toHaveBeenCalled(); }); @@ -62,7 +62,7 @@ describe('addDatabaseInstrumentation', () => { const nitroConfig: NitroConfig = {}; const moduleOptions: SentryNuxtModuleOptions = {}; - addDatabaseInstrumentation(nitroConfig, moduleOptions); + addDatabaseInstrumentation(nitroConfig, false, moduleOptions); expect(consoleLogSpy).not.toHaveBeenCalled(); }); @@ -71,7 +71,7 @@ describe('addDatabaseInstrumentation', () => { const nitroConfig: NitroConfig = { experimental: { database: false } }; const moduleOptions: SentryNuxtModuleOptions = { debug: true }; - addDatabaseInstrumentation(nitroConfig, moduleOptions); + addDatabaseInstrumentation(nitroConfig, false, moduleOptions); expect(consoleLogSpy).toHaveBeenCalledWith( '[Sentry] [Nitro Database Plugin]: No database configuration found. Skipping database instrumentation.', diff --git a/packages/nuxt/test/vite/utils.test.ts b/packages/nuxt/test/vite/utils.test.ts index 4911a06b6f2f..2be73259305a 100644 --- a/packages/nuxt/test/vite/utils.test.ts +++ b/packages/nuxt/test/vite/utils.test.ts @@ -427,4 +427,14 @@ describe('addOTelCommonJSImportAlias', () => { expect(nuxtMock.options.alias).toBeUndefined(); }); + + it('does not add alias when in Nitro v3+ (Rolldown incompatibility)', () => { + const nuxtMock: Nuxt = { + options: { dev: true }, + } as unknown as Nuxt; + + addOTelCommonJSImportAlias(nuxtMock, true); + + expect(nuxtMock.options.alias).toBeUndefined(); + }); }); From a54de04744477a59d123598e8432600d696116a1 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 25 Mar 2026 13:02:03 +0100 Subject: [PATCH 41/43] ref(core): Remove duplicate `buildMethodPath` utility from openai (#19969) This is an exact copy from our shared ai utilities Closes #19970 (added automatically) --- packages/core/src/tracing/openai/index.ts | 2 +- packages/core/src/tracing/openai/utils.ts | 7 ------- packages/core/test/lib/utils/openai-utils.test.ts | 2 +- 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/core/src/tracing/openai/index.ts b/packages/core/src/tracing/openai/index.ts index afcf853f2a9d..d5ee3f53af86 100644 --- a/packages/core/src/tracing/openai/index.ts +++ b/packages/core/src/tracing/openai/index.ts @@ -22,6 +22,7 @@ import { getTruncatedJsonString, resolveAIRecordingOptions, wrapPromiseWithMethods, + buildMethodPath, } from '../ai/utils'; import { instrumentStream } from './streaming'; import type { @@ -37,7 +38,6 @@ import { addConversationAttributes, addEmbeddingsAttributes, addResponsesApiAttributes, - buildMethodPath, extractRequestParameters, getOperationName, getSpanOperation, diff --git a/packages/core/src/tracing/openai/utils.ts b/packages/core/src/tracing/openai/utils.ts index 3338d4524d75..f89b786b5a3c 100644 --- a/packages/core/src/tracing/openai/utils.ts +++ b/packages/core/src/tracing/openai/utils.ts @@ -69,13 +69,6 @@ export function shouldInstrument(methodPath: string): methodPath is Instrumented return INSTRUMENTED_METHODS.includes(methodPath as InstrumentedMethod); } -/** - * Build method path from current traversal - */ -export function buildMethodPath(currentPath: string, prop: string): string { - return currentPath ? `${currentPath}.${prop}` : prop; -} - /** * Check if response is a Chat Completion object */ diff --git a/packages/core/test/lib/utils/openai-utils.test.ts b/packages/core/test/lib/utils/openai-utils.test.ts index 25cd873ace08..65a55bcc9ef6 100644 --- a/packages/core/test/lib/utils/openai-utils.test.ts +++ b/packages/core/test/lib/utils/openai-utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { buildMethodPath } from '../../../src/tracing/ai/utils'; import { - buildMethodPath, getOperationName, getSpanOperation, isChatCompletionChunk, From 54abb35ca5f86fad48b11fd8d55adb01ec95fa42 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 25 Mar 2026 08:38:16 -0400 Subject: [PATCH 42/43] refactor(elysia): drop @elysiajs/opentelemetry dependency (#19947) Mimics `@elysia/opentelemetry` own implementation and swaps out OTEL APIs with our own, main reason is I didn't want to lock OTEL deps to specific versions that may break in the wild. This is an exploration, but if all is green then I will consider merging this first into Elysia SDK before release. closes #18956 --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .size-limit.js | 26 +- .../test-applications/elysia-bun/package.json | 1 - .../elysia-bun/tests/transactions.test.ts | 25 +- .../elysia-node/package.json | 1 - .../elysia-node/tests/transactions.test.ts | 25 +- package.json | 6 +- packages/elysia/package.json | 11 +- packages/elysia/src/clientHooks.ts | 68 ---- packages/elysia/src/withElysia.ts | 239 +++++++++++-- packages/elysia/test/withElysia.test.ts | 18 +- yarn.lock | 332 +++--------------- 11 files changed, 304 insertions(+), 448 deletions(-) delete mode 100644 packages/elysia/src/clientHooks.ts diff --git a/.size-limit.js b/.size-limit.js index 5141775bf367..3e0902c0a57c 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -184,7 +184,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing)', path: createCDNPath('bundle.tracing.min.js'), gzip: true, - limit: '45 KB', + limit: '44 KB', }, { name: 'CDN Bundle (incl. Logs, Metrics)', @@ -196,37 +196,37 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Logs, Metrics)', path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: true, - limit: '46 KB', + limit: '45 KB', }, { name: 'CDN Bundle (incl. Replay, Logs, Metrics)', path: createCDNPath('bundle.replay.logs.metrics.min.js'), gzip: true, - limit: '70 KB', + limit: '69 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay)', path: createCDNPath('bundle.tracing.replay.min.js'), gzip: true, - limit: '82 KB', + limit: '81 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics)', path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: true, - limit: '83 KB', + limit: '82 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback)', path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: true, - limit: '88 KB', + limit: '86 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)', path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: true, - limit: '88 KB', + limit: '87 KB', }, // browser CDN bundles (non-gzipped) { @@ -241,7 +241,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '133 KB', + limit: '129 KB', }, { name: 'CDN Bundle (incl. Logs, Metrics) - uncompressed', @@ -255,28 +255,28 @@ module.exports = [ path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '136 KB', + limit: '132 KB', }, { name: 'CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed', path: createCDNPath('bundle.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '212 KB', + limit: '210 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed', path: createCDNPath('bundle.tracing.replay.min.js'), gzip: false, brotli: false, - limit: '250 KB', + limit: '246 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed', path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '253 KB', + limit: '250 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed', @@ -290,7 +290,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '266 KB', + limit: '264 KB', }, // Next.js SDK (ESM) { diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/package.json b/dev-packages/e2e-tests/test-applications/elysia-bun/package.json index dd0123725147..73689db97994 100644 --- a/dev-packages/e2e-tests/test-applications/elysia-bun/package.json +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/package.json @@ -11,7 +11,6 @@ "test:assert": "pnpm test" }, "dependencies": { - "@elysiajs/opentelemetry": "^1.4.0", "@sentry/elysia": "latest || *", "elysia": "^1.4.0" }, diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/elysia-bun/tests/transactions.test.ts index e0f05a1be483..b0b21fb9227f 100644 --- a/dev-packages/e2e-tests/test-applications/elysia-bun/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/tests/transactions.test.ts @@ -125,7 +125,7 @@ test('Creates lifecycle spans for Elysia hooks', async ({ baseURL, request }) => const spans = transactionEvent.spans || []; // Elysia should produce lifecycle spans enriched with sentry attributes - const elysiaSpans = spans.filter(span => span.origin === 'auto.http.otel.elysia'); + const elysiaSpans = spans.filter(span => span.origin === 'auto.http.elysia'); expect(elysiaSpans.length).toBeGreaterThan(0); // The Handle span should be present as a request handler @@ -133,30 +133,35 @@ test('Creates lifecycle spans for Elysia hooks', async ({ baseURL, request }) => expect.objectContaining({ description: 'Handle', op: 'request_handler.elysia', - origin: 'auto.http.otel.elysia', + origin: 'auto.http.elysia', }), ); }); -test('Filters out empty anonymous Elysia spans but keeps all other spans', async ({ baseURL, request }) => { +test('Names anonymous handler spans as "anonymous" instead of ""', async ({ baseURL, request }) => { const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /test-success' + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /with-middleware/test' ); }); - await request.get(`${baseURL}/test-success`); + // Use a route with middleware so there are child handler spans + await request.get(`${baseURL}/with-middleware/test`); const transactionEvent = await transactionEventPromise; const spans = transactionEvent.spans || []; - // Elysia produces empty anonymous spans for arrow function handlers that show up as . - // These should be filtered out by our beforeSendEvent hook. + // No spans should exist — we name them 'anonymous' instead const unknownSpans = spans.filter(span => span.description === ''); expect(unknownSpans).toHaveLength(0); - // But named Elysia lifecycle spans should still be present - expect(spans.filter(span => span.origin === 'auto.http.otel.elysia').length).toBeGreaterThan(0); + // Anonymous handler spans should be named 'anonymous' + const anonymousSpans = spans.filter(span => span.description === 'anonymous' && span.origin === 'auto.http.elysia'); + expect(anonymousSpans.length).toBeGreaterThan(0); + + // Named Elysia lifecycle spans should still be present + expect(spans.filter(span => span.origin === 'auto.http.elysia').length).toBeGreaterThan(0); }); test('Creates lifecycle spans for route-specific middleware', async ({ baseURL, request }) => { @@ -177,7 +182,7 @@ test('Creates lifecycle spans for route-specific middleware', async ({ baseURL, expect.objectContaining({ description: 'BeforeHandle', op: 'middleware.elysia', - origin: 'auto.http.otel.elysia', + origin: 'auto.http.elysia', }), ); }); diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/package.json b/dev-packages/e2e-tests/test-applications/elysia-node/package.json index 920c1fb019ce..dda646fab480 100644 --- a/dev-packages/e2e-tests/test-applications/elysia-node/package.json +++ b/dev-packages/e2e-tests/test-applications/elysia-node/package.json @@ -11,7 +11,6 @@ "test:assert": "pnpm test" }, "dependencies": { - "@elysiajs/opentelemetry": "^1.4.10", "@sentry/elysia": "latest || *", "elysia": "latest", "@elysiajs/node": "^1.4.5" diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/elysia-node/tests/transactions.test.ts index 69ee7c48acf3..941341c3422a 100644 --- a/dev-packages/e2e-tests/test-applications/elysia-node/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/elysia-node/tests/transactions.test.ts @@ -125,7 +125,7 @@ test('Creates lifecycle spans for Elysia hooks', async ({ baseURL, request }) => const spans = transactionEvent.spans || []; // Elysia should produce lifecycle spans enriched with sentry attributes - const elysiaSpans = spans.filter(span => span.origin === 'auto.http.otel.elysia'); + const elysiaSpans = spans.filter(span => span.origin === 'auto.http.elysia'); expect(elysiaSpans.length).toBeGreaterThan(0); // The Handle span should be present as a request handler @@ -133,30 +133,35 @@ test('Creates lifecycle spans for Elysia hooks', async ({ baseURL, request }) => expect.objectContaining({ description: 'Handle', op: 'request_handler.elysia', - origin: 'auto.http.otel.elysia', + origin: 'auto.http.elysia', }), ); }); -test('Filters out empty anonymous Elysia spans but keeps all other spans', async ({ baseURL, request }) => { +test('Names anonymous handler spans as "anonymous" instead of ""', async ({ baseURL, request }) => { const transactionEventPromise = waitForTransaction('elysia-node', transactionEvent => { return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /test-success' + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /with-middleware/test' ); }); - await request.get(`${baseURL}/test-success`); + // Use a route with middleware so there are child handler spans + await request.get(`${baseURL}/with-middleware/test`); const transactionEvent = await transactionEventPromise; const spans = transactionEvent.spans || []; - // Elysia produces empty anonymous spans for arrow function handlers that show up as . - // These should be filtered out by our beforeSendEvent hook. + // No spans should exist — we name them 'anonymous' instead const unknownSpans = spans.filter(span => span.description === ''); expect(unknownSpans).toHaveLength(0); - // But named Elysia lifecycle spans should still be present - expect(spans.filter(span => span.origin === 'auto.http.otel.elysia').length).toBeGreaterThan(0); + // Anonymous handler spans should be named 'anonymous' + const anonymousSpans = spans.filter(span => span.description === 'anonymous' && span.origin === 'auto.http.elysia'); + expect(anonymousSpans.length).toBeGreaterThan(0); + + // Named Elysia lifecycle spans should still be present + expect(spans.filter(span => span.origin === 'auto.http.elysia').length).toBeGreaterThan(0); }); test('Creates lifecycle spans for route-specific middleware', async ({ baseURL, request }) => { @@ -177,7 +182,7 @@ test('Creates lifecycle spans for route-specific middleware', async ({ baseURL, expect.objectContaining({ description: 'BeforeHandle', op: 'middleware.elysia', - origin: 'auto.http.otel.elysia', + origin: 'auto.http.elysia', }), ); }); diff --git a/package.json b/package.json index 547b6a065fdb..ccb2f051319a 100644 --- a/package.json +++ b/package.json @@ -158,11 +158,7 @@ "wide-align/string-width": "4.2.3", "cliui/wrap-ansi": "7.0.0", "sucrase": "getsentry/sucrase#es2020-polyfills", - "**/express/path-to-regexp": "0.1.12", - "**/@opentelemetry/core": "2.6.0", - "**/@opentelemetry/resources": "2.6.0", - "**/@opentelemetry/sdk-trace-base": "2.6.0", - "**/@opentelemetry/instrumentation": "0.213.0" + "**/express/path-to-regexp": "0.1.12" }, "version": "0.0.0", "name": "sentry-javascript" diff --git a/packages/elysia/package.json b/packages/elysia/package.json index 18206f0c91e4..86d60bfe822f 100644 --- a/packages/elysia/package.json +++ b/packages/elysia/package.json @@ -43,20 +43,15 @@ "@sentry/core": "10.45.0" }, "peerDependencies": { - "elysia": "^1.4.0", - "@elysiajs/opentelemetry": "^1.4.0" + "elysia": "^1.4.0" }, "devDependencies": { - "@elysiajs/opentelemetry": "^1.4.0", "bun-types": "^1.2.9", "elysia": "^1.4.0" }, "peerDependenciesMeta": { "elysia": { "optional": false - }, - "@elysiajs/opentelemetry": { - "optional": false } }, "scripts": { @@ -72,8 +67,8 @@ "build:tarball": "npm pack", "circularDepCheck": "madge --circular src/index.ts", "clean": "rimraf build coverage sentry-elysia-*.tgz", - "fix": "eslint . --format stylish --fix", - "lint": "eslint . --format stylish", + "lint": "OXLINT_TSGOLINT_DANGEROUSLY_SUPPRESS_PROGRAM_DIAGNOSTICS=true oxlint . --type-aware", + "lint:fix": "OXLINT_TSGOLINT_DANGEROUSLY_SUPPRESS_PROGRAM_DIAGNOSTICS=true oxlint . --fix --type-aware", "lint:es-compatibility": "es-check es2022 ./build/cjs/*.js && es-check es2022 ./build/esm/*.js --module", "test": "vitest run", "test:watch": "vitest --watch", diff --git a/packages/elysia/src/clientHooks.ts b/packages/elysia/src/clientHooks.ts deleted file mode 100644 index df77d2ca2612..000000000000 --- a/packages/elysia/src/clientHooks.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { Client, Event, Span } from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, spanToJSON } from '@sentry/core'; - -const ELYSIA_ORIGIN = 'auto.http.otel.elysia'; - -const ELYSIA_LIFECYCLE_OP_MAP: Record = { - Request: 'middleware.elysia', - Parse: 'middleware.elysia', - Transform: 'middleware.elysia', - BeforeHandle: 'middleware.elysia', - Handle: 'request_handler.elysia', - AfterHandle: 'middleware.elysia', - MapResponse: 'middleware.elysia', - AfterResponse: 'middleware.elysia', - Error: 'middleware.elysia', -}; - -/** - * Enrich Elysia lifecycle spans with semantic op and origin, - * and filter out empty anonymous child spans that Elysia produces. - */ -export function setupClientHooks(client: Client): void { - // Enrich Elysia lifecycle spans with semantic op and origin. - // We mutate the attributes directly because the span has already ended - // and `setAttribute()` is a no-op on ended OTel spans. - client.on('spanEnd', (span: Span) => { - const spanData = spanToJSON(span); - const op = ELYSIA_LIFECYCLE_OP_MAP[spanData.description || '']; - if (op && spanData.data) { - const attrs = spanData.data; - attrs[SEMANTIC_ATTRIBUTE_SENTRY_OP] = op; - attrs[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = ELYSIA_ORIGIN; - } - }); - - // Filter out empty child spans that Elysia produces for each function handler. - // Users usually use arrow functions so they show up as . - // We identify Elysia spans by checking if their parent is an Elysia lifecycle span - // (one we enriched with our origin), so we don't accidentally drop spans from other integrations. - client.on('beforeSendEvent', (event: Event) => { - if (event.type === 'transaction' && event.spans) { - const elysiaSpanIds = new Set(); - const filteredSpans: typeof event.spans = []; - - for (const span of event.spans) { - // Accumulate IDs of Elysia lifecycle spans - if (span.origin === ELYSIA_ORIGIN) { - elysiaSpanIds.add(span.span_id); - } - - // Decide whether to keep the span - if ( - (!span.description || span.description === '') && - span.parent_span_id && - elysiaSpanIds.has(span.parent_span_id) - ) { - continue; // filter out - } - filteredSpans.push(span); - } - - // Only update if we filtered something out (or could have) - if (elysiaSpanIds.size > 0) { - event.spans = filteredSpans; - } - } - }); -} diff --git a/packages/elysia/src/withElysia.ts b/packages/elysia/src/withElysia.ts index 9efeee39481a..da3600ad317d 100644 --- a/packages/elysia/src/withElysia.ts +++ b/packages/elysia/src/withElysia.ts @@ -1,41 +1,77 @@ -import { opentelemetry } from '@elysiajs/opentelemetry'; +import type { Span } from '@sentry/core'; import { captureException, + continueTrace, getActiveSpan, - getClient, getIsolationScope, getRootSpan, getTraceData, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + setHttpStatus, + startInactiveSpan, + startSpanManual, updateSpanName, winterCGRequestToRequestData, + withIsolationScope, } from '@sentry/core'; -import type { Elysia, ErrorContext } from 'elysia'; -import { setupClientHooks } from './clientHooks'; +import type { Elysia, ErrorContext, TraceHandler, TraceListener } from 'elysia'; interface ElysiaHandlerOptions { shouldHandleError?: (context: ErrorContext) => boolean; } +const ELYSIA_ORIGIN = 'auto.http.elysia'; + +/** + * Map Elysia lifecycle phase names to Sentry span ops. + */ +const ELYSIA_LIFECYCLE_OP_MAP: Record = { + Request: 'middleware.elysia', + Parse: 'middleware.elysia', + Transform: 'middleware.elysia', + BeforeHandle: 'middleware.elysia', + Handle: 'request_handler.elysia', + AfterHandle: 'middleware.elysia', + MapResponse: 'middleware.elysia', + AfterResponse: 'middleware.elysia', + Error: 'middleware.elysia', +}; + function isBun(): boolean { return typeof Bun !== 'undefined'; } -let isClientHooksSetup = false; +/** + * Per-request storage for the root span reference. + * .wrap() captures the root span and .trace() reads it. + * This is necessary because Elysia's .trace() callbacks may run in a different + * async context where getActiveSpan() returns undefined. + */ +const rootSpanForRequest = new WeakMap(); + const instrumentedApps = new WeakSet(); /** * Updates the root span and isolation scope with the parameterized route name. - * Only needed on Node.js where the root span comes from HTTP instrumentation. */ -function updateRouteTransactionName(method: string, route: string): void { +function updateRouteTransactionName(request: Request, method: string, route: string): void { const transactionName = `${method} ${route}`; - const activeSpan = getActiveSpan(); - if (activeSpan) { - const rootSpan = getRootSpan(activeSpan); + // Try the stored root span first (reliable across async contexts), + // then fall back to getActiveSpan() for cases where async context is preserved. + const rootSpan = rootSpanForRequest.get(request); + if (rootSpan) { updateSpanName(rootSpan, transactionName); rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + } else { + const activeSpan = getActiveSpan(); + if (activeSpan) { + const root = getRootSpan(activeSpan); + updateSpanName(root, transactionName); + root.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + } } getIsolationScope().setTransactionName(transactionName); @@ -55,6 +91,56 @@ function defaultShouldHandleError(context: ErrorContext): boolean { return statusCode >= 500 || statusCode <= 299; } +/** + * Instruments a single Elysia lifecycle phase by creating a Sentry span for it, + * and child spans for each individual handler within the phase. + * + * @param rootSpan - The root server span to parent lifecycle spans under. + * Must be passed explicitly because Elysia's .trace() listener callbacks run + * in a different async context where getActiveSpan() returns undefined. + */ +function instrumentLifecyclePhase(phaseName: string, listener: TraceListener, rootSpan: Span | undefined): void { + const op = ELYSIA_LIFECYCLE_OP_MAP[phaseName]; + if (!op) { + return; + } + + void listener(process => { + const phaseSpan = startInactiveSpan({ + name: phaseName, + parentSpan: rootSpan, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ELYSIA_ORIGIN, + }, + }); + + // Create child spans for individual handlers within this phase. + // Named functions get their name, arrow functions get 'anonymous'. + if (process.total > 0) { + void process.onEvent(child => { + const handlerName = child.name || 'anonymous'; + const childSpan = startInactiveSpan({ + name: handlerName, + parentSpan: phaseSpan, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ELYSIA_ORIGIN, + }, + }); + + void child.onStop(() => { + childSpan.end(); + }); + }); + } + + void process.onStop(() => { + phaseSpan.end(); + }); + }); +} + /** * Integrate Sentry with an Elysia app for error handling, request context, * and tracing. Returns the app instance for chaining. @@ -81,32 +167,117 @@ export function withElysia(app: T, options: ElysiaHandlerOptio } instrumentedApps.add(app); - // Register the opentelemetry plugin - // https://elysiajs.com/plugins/opentelemetry - app.use(opentelemetry()); + // Use .wrap() to capture or create the root span for each request. + // This is necessary because Elysia's .trace() callbacks run in a different + // async context where getActiveSpan() returns undefined. By storing the root + // span in a WeakMap keyed by the Request object, we can retrieve it in .trace(). + // HigherOrderFunction type is not exported from elysia's main entry point, + // so we type the callback parameters directly. + app.wrap((fn: (...args: unknown[]) => unknown, request: Request) => { + if (isBun()) { + // On Bun there is no HTTP instrumentation, so we create a root span ourselves. + // Scope setup must happen inside the returned function so that it's active + // when Elysia calls the handler (not during .wrap() registration). + return (...args: unknown[]) => { + return withIsolationScope(() => { + return continueTrace( + { + sentryTrace: request.headers.get('sentry-trace') || '', + baggage: request.headers.get('baggage'), + }, + () => { + return startSpanManual( + { + op: 'http.server', + name: `${request.method} ${new URL(request.url).pathname}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ELYSIA_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, + }, + rootSpan => { + rootSpanForRequest.set(request, rootSpan); + try { + const result = fn(...args); + if (result instanceof Promise) { + return result.then( + res => { + rootSpanForRequest.delete(request); + rootSpan.end(); + return res; + }, + err => { + rootSpanForRequest.delete(request); + rootSpan.end(); + throw err; + }, + ); + } + rootSpanForRequest.delete(request); + rootSpan.end(); + return result; + } catch (err) { + rootSpanForRequest.delete(request); + rootSpan.end(); + throw err; + } + }, + ); + }, + ); + }); + }; + } - if (!isClientHooksSetup) { - const client = getClient(); - if (client) { - isClientHooksSetup = true; - setupClientHooks(client); + // On Node.js, the HTTP instrumentation already creates a root span. + // We just capture its reference so .trace() can use it. + const activeSpan = getActiveSpan(); + if (activeSpan) { + rootSpanForRequest.set(request, getRootSpan(activeSpan)); } - } + return fn; + }); + + // Use .trace() ONLY for span creation. The trace API is observational — + // callbacks fire after phases complete, so they can't reliably mutate + // response headers or capture errors. All SDK logic stays in real hooks. + const traceHandler: TraceHandler = lifecycle => { + const rootSpan = rootSpanForRequest.get(lifecycle.context.request); - // Set SDK processing metadata for all requests - app.onRequest(context => { + const phases: [string, TraceListener][] = [ + ['Request', lifecycle.onRequest], + ['Parse', lifecycle.onParse], + ['Transform', lifecycle.onTransform], + ['BeforeHandle', lifecycle.onBeforeHandle], + ['Handle', lifecycle.onHandle], + ['AfterHandle', lifecycle.onAfterHandle], + ['MapResponse', lifecycle.onMapResponse], + ['AfterResponse', lifecycle.onAfterResponse], + ['Error', lifecycle.onError], + ]; + + for (const [phaseName, listener] of phases) { + if (listener) { + instrumentLifecyclePhase(phaseName, listener, rootSpan); + } + } + }; + + app.trace({ as: 'global' }, traceHandler); + + // SDK logic uses real lifecycle hooks — these show up as handler spans + // in the trace (named sentryOnRequest etc.), but that's the correct + // tradeoff: the trace API can't reliably mutate state or capture errors. + + app.onRequest(function sentryOnRequest(context) { getIsolationScope().setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(context.request), }); }); - // Propagate trace data to all response headers and update transaction name - app.onAfterHandle({ as: 'global' }, context => { - // On Node.js, the root span is created by the HTTP instrumentation and only has the raw URL. - // The Elysia OTel plugin creates a child span with route info, but we need to propagate it up. - // On Bun, the Elysia OTel plugin already handles the root span correctly. - if (!isBun() && context.route) { - updateRouteTransactionName(context.request.method, context.route); + app.onAfterHandle({ as: 'global' }, function sentryOnAfterHandle(context) { + if (context.route) { + updateRouteTransactionName(context.request, context.request.method, context.route); } const traceData = getTraceData(); @@ -118,10 +289,16 @@ export function withElysia(app: T, options: ElysiaHandlerOptio } }); - // Register the error handler for all routes - app.onError({ as: 'global' }, context => { + app.onError({ as: 'global' }, function sentryOnError(context) { if (context.route) { - updateRouteTransactionName(context.request.method, context.route); + updateRouteTransactionName(context.request, context.request.method, context.route); + } + + // Set error status on root span + const rootSpan = rootSpanForRequest.get(context.request); + if (rootSpan) { + const statusCode = parseInt(String(context.set.status), 10); + setHttpStatus(rootSpan, Number.isNaN(statusCode) ? 500 : statusCode); } const shouldHandleError = options?.shouldHandleError || defaultShouldHandleError; diff --git a/packages/elysia/test/withElysia.test.ts b/packages/elysia/test/withElysia.test.ts index 0bed50248af1..3f73d9e5d835 100644 --- a/packages/elysia/test/withElysia.test.ts +++ b/packages/elysia/test/withElysia.test.ts @@ -1,13 +1,15 @@ import type { ErrorContext } from 'elysia'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -// Capture the handlers registered by withElysia +// Capture handlers registered by withElysia let onAfterHandleHandler: (context: unknown) => void; let onErrorHandler: (context: unknown) => void; function createMockApp() { const app: Record = {}; app.use = vi.fn().mockReturnValue(app); + app.wrap = vi.fn().mockReturnValue(app); + app.trace = vi.fn().mockReturnValue(app); app.onRequest = vi.fn(() => app); app.onAfterHandle = vi.fn((_opts: unknown, handler: (context: unknown) => void) => { onAfterHandleHandler = handler; @@ -35,10 +37,6 @@ const mockGetTraceData = vi.fn(() => ({ baggage: 'sentry-environment=test,sentry-trace_id=abc123', })); -vi.mock('@elysiajs/opentelemetry', () => ({ - opentelemetry: vi.fn(() => 'otel-plugin'), -})); - vi.mock('@sentry/core', async importActual => { // eslint-disable-next-line @typescript-eslint/consistent-type-imports const actual = await importActual(); @@ -63,15 +61,11 @@ describe('withElysia', () => { vi.clearAllMocks(); }); - it('registers opentelemetry plugin', () => { - // @ts-expect-error - mock app - withElysia(mockApp); - expect(mockApp.use).toHaveBeenCalledWith('otel-plugin'); - }); - - it('registers onRequest, onAfterHandle, and onError hooks', () => { + it('registers .wrap(), .trace(), and lifecycle hooks', () => { // @ts-expect-error - mock app withElysia(mockApp); + expect(mockApp.wrap).toHaveBeenCalledWith(expect.any(Function)); + expect(mockApp.trace).toHaveBeenCalledWith({ as: 'global' }, expect.any(Function)); expect(mockApp.onRequest).toHaveBeenCalled(); expect(mockApp.onAfterHandle).toHaveBeenCalledWith({ as: 'global' }, expect.any(Function)); expect(mockApp.onError).toHaveBeenCalledWith({ as: 'global' }, expect.any(Function)); diff --git a/yarn.lock b/yarn.lock index 375e86ddd052..b4340f8ab7d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3272,15 +3272,6 @@ resolved "https://registry.yarnpkg.com/@effect/vitest/-/vitest-0.23.13.tgz#17edf9d8e3443f080ff8fe93bd37b023612a07a4" integrity sha512-F3x2phMXuVzqWexdcYp8v0z1qQHkKxp2UaHNbqZaEjPEp8FBz/iMwbi6iS/oIWzLfGF8XqdP8BGJptvGIJONNw== -"@elysiajs/opentelemetry@^1.4.0": - version "1.4.10" - resolved "https://registry.yarnpkg.com/@elysiajs/opentelemetry/-/opentelemetry-1.4.10.tgz#1c107d9071fbc284737f7b47cdf15554ef075521" - integrity sha512-2GH187Rr3n3Rq+R7fogn/jcmdwWk9OMtbYhnaJg5ydiLOJvtrztDp0p+zbyGFG2gspx8U9vpaCvSJ69Aq1zZkA== - dependencies: - "@opentelemetry/api" "^1.9.0" - "@opentelemetry/instrumentation" "^0.200.0" - "@opentelemetry/sdk-node" "^0.200.0" - "@ember-data/rfc395-data@^0.0.4": version "0.0.4" resolved "https://registry.yarnpkg.com/@ember-data/rfc395-data/-/rfc395-data-0.0.4.tgz#ecb86efdf5d7733a76ff14ea651a1b0ed1f8a843" @@ -4645,24 +4636,6 @@ dependencies: dom-mutator "^0.6.0" -"@grpc/grpc-js@^1.7.1": - version "1.14.3" - resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.14.3.tgz#4c9b817a900ae4020ddc28515ae4b52c78cfb8da" - integrity sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA== - dependencies: - "@grpc/proto-loader" "^0.8.0" - "@js-sdsl/ordered-map" "^4.4.2" - -"@grpc/proto-loader@^0.8.0": - version "0.8.0" - resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.8.0.tgz#b6c324dd909c458a0e4aa9bfd3d69cf78a4b9bd8" - integrity sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ== - dependencies: - lodash.camelcase "^4.3.0" - long "^5.0.0" - protobufjs "^7.5.3" - yargs "^17.7.2" - "@handlebars/parser@~2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@handlebars/parser/-/parser-2.0.0.tgz#5e8b7298f31ff8f7b260e6b7363c7e9ceed7d9c5" @@ -5232,11 +5205,6 @@ resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-5.7.0.tgz#526d437b07cbb41e28df34d487cbfccbe730185b" integrity sha512-WBu4ULVVxySLLzK1Ppq+OdfP+adRS4ntmDQT915rzDJ++i95gc2jZkM5B6LWEAwN3lGXpfie3yPABozdD3K3Vg== -"@js-sdsl/ordered-map@^4.4.2": - version "4.4.2" - resolved "https://registry.yarnpkg.com/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz#9299f82874bab9e4c7f9c48d865becbfe8d6907c" - integrity sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw== - "@kwsites/file-exists@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@kwsites/file-exists/-/file-exists-1.1.1.tgz#ad1efcac13e1987d8dbaf235ef3be5b0d96faa99" @@ -6219,10 +6187,17 @@ dependencies: "@octokit/openapi-types" "^12.11.0" -"@opentelemetry/api-logs@0.200.0": - version "0.200.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.200.0.tgz#f9015fd844920c13968715b3cdccf5a4d4ff907e" - integrity sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q== +"@opentelemetry/api-logs@0.207.0": + version "0.207.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz#ae991c51eedda55af037a3e6fc1ebdb12b289f49" + integrity sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ== + dependencies: + "@opentelemetry/api" "^1.3.0" + +"@opentelemetry/api-logs@0.212.0": + version "0.212.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.212.0.tgz#ec66a0951b84b1f082e13fd8a027b9f9d65a3f7a" + integrity sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg== dependencies: "@opentelemetry/api" "^1.3.0" @@ -6238,150 +6213,18 @@ resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe" integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg== -"@opentelemetry/context-async-hooks@2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/context-async-hooks/-/context-async-hooks-2.0.0.tgz#c98a727238ca199cda943780acf6124af8d8cd80" - integrity sha512-IEkJGzK1A9v3/EHjXh3s2IiFc6L4jfK+lNgKVgUjeUJQRRhnVFMIO3TAvKwonm9O1HebCuoOt98v8bZW7oVQHA== - "@opentelemetry/context-async-hooks@^2.6.0": version "2.6.0" resolved "https://registry.yarnpkg.com/@opentelemetry/context-async-hooks/-/context-async-hooks-2.6.0.tgz#6c824e900630b378233c1a78ca7f0dc5a3b460b2" integrity sha512-L8UyDwqpTcbkIK5cgwDRDYDoEhQoj8wp8BwsO19w3LB1Z41yEQm2VJyNfAi9DrLP/YTqXqWpKHyZfR9/tFYo1Q== -"@opentelemetry/core@2.0.0", "@opentelemetry/core@2.6.0", "@opentelemetry/core@^2.0.0", "@opentelemetry/core@^2.6.0": +"@opentelemetry/core@2.6.0", "@opentelemetry/core@^2.0.0", "@opentelemetry/core@^2.6.0": version "2.6.0" resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-2.6.0.tgz#719c829ed98bd7af808a2d2c83374df1fd1f3c66" integrity sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg== dependencies: "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/exporter-logs-otlp-grpc@0.200.0": - version "0.200.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.200.0.tgz#693e0f7041c533061d0689ab43d64d039078ee7a" - integrity sha512-+3MDfa5YQPGM3WXxW9kqGD85Q7s9wlEMVNhXXG7tYFLnIeaseUt9YtCeFhEDFzfEktacdFpOtXmJuNW8cHbU5A== - dependencies: - "@grpc/grpc-js" "^1.7.1" - "@opentelemetry/core" "2.0.0" - "@opentelemetry/otlp-exporter-base" "0.200.0" - "@opentelemetry/otlp-grpc-exporter-base" "0.200.0" - "@opentelemetry/otlp-transformer" "0.200.0" - "@opentelemetry/sdk-logs" "0.200.0" - -"@opentelemetry/exporter-logs-otlp-http@0.200.0": - version "0.200.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.200.0.tgz#3a99c9554f871b5c6cddb8716316c125d4edca6c" - integrity sha512-KfWw49htbGGp9s8N4KI8EQ9XuqKJ0VG+yVYVYFiCYSjEV32qpQ5qZ9UZBzOZ6xRb+E16SXOSCT3RkqBVSABZ+g== - dependencies: - "@opentelemetry/api-logs" "0.200.0" - "@opentelemetry/core" "2.0.0" - "@opentelemetry/otlp-exporter-base" "0.200.0" - "@opentelemetry/otlp-transformer" "0.200.0" - "@opentelemetry/sdk-logs" "0.200.0" - -"@opentelemetry/exporter-logs-otlp-proto@0.200.0": - version "0.200.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.200.0.tgz#53573ea43bce4129bcb18bda172a95c6535bb1a2" - integrity sha512-GmahpUU/55hxfH4TP77ChOfftADsCq/nuri73I/AVLe2s4NIglvTsaACkFVZAVmnXXyPS00Fk3x27WS3yO07zA== - dependencies: - "@opentelemetry/api-logs" "0.200.0" - "@opentelemetry/core" "2.0.0" - "@opentelemetry/otlp-exporter-base" "0.200.0" - "@opentelemetry/otlp-transformer" "0.200.0" - "@opentelemetry/resources" "2.0.0" - "@opentelemetry/sdk-logs" "0.200.0" - "@opentelemetry/sdk-trace-base" "2.0.0" - -"@opentelemetry/exporter-metrics-otlp-grpc@0.200.0": - version "0.200.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.200.0.tgz#f9a4d209083a6a12489c4ae4c20e6923a1780c88" - integrity sha512-uHawPRvKIrhqH09GloTuYeq2BjyieYHIpiklOvxm9zhrCL2eRsnI/6g9v2BZTVtGp8tEgIa7rCQ6Ltxw6NBgew== - dependencies: - "@grpc/grpc-js" "^1.7.1" - "@opentelemetry/core" "2.0.0" - "@opentelemetry/exporter-metrics-otlp-http" "0.200.0" - "@opentelemetry/otlp-exporter-base" "0.200.0" - "@opentelemetry/otlp-grpc-exporter-base" "0.200.0" - "@opentelemetry/otlp-transformer" "0.200.0" - "@opentelemetry/resources" "2.0.0" - "@opentelemetry/sdk-metrics" "2.0.0" - -"@opentelemetry/exporter-metrics-otlp-http@0.200.0": - version "0.200.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.200.0.tgz#daa28a2b868bacf02efb153fa8780d078807919e" - integrity sha512-5BiR6i8yHc9+qW7F6LqkuUnIzVNA7lt0qRxIKcKT+gq3eGUPHZ3DY29sfxI3tkvnwMgtnHDMNze5DdxW39HsAw== - dependencies: - "@opentelemetry/core" "2.0.0" - "@opentelemetry/otlp-exporter-base" "0.200.0" - "@opentelemetry/otlp-transformer" "0.200.0" - "@opentelemetry/resources" "2.0.0" - "@opentelemetry/sdk-metrics" "2.0.0" - -"@opentelemetry/exporter-metrics-otlp-proto@0.200.0": - version "0.200.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.200.0.tgz#5a494e2df8703be2f1f5f01629dfd48a6d39e5a6" - integrity sha512-E+uPj0yyvz81U9pvLZp3oHtFrEzNSqKGVkIViTQY1rH3TOobeJPSpLnTVXACnCwkPR5XeTvPnK3pZ2Kni8AFMg== - dependencies: - "@opentelemetry/core" "2.0.0" - "@opentelemetry/exporter-metrics-otlp-http" "0.200.0" - "@opentelemetry/otlp-exporter-base" "0.200.0" - "@opentelemetry/otlp-transformer" "0.200.0" - "@opentelemetry/resources" "2.0.0" - "@opentelemetry/sdk-metrics" "2.0.0" - -"@opentelemetry/exporter-prometheus@0.200.0": - version "0.200.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.200.0.tgz#8f3dd3a8903447563a5be30ddf9e7bfb1e7ad127" - integrity sha512-ZYdlU9r0USuuYppiDyU2VFRA0kFl855ylnb3N/2aOlXrbA4PMCznen7gmPbetGQu7pz8Jbaf4fwvrDnVdQQXSw== - dependencies: - "@opentelemetry/core" "2.0.0" - "@opentelemetry/resources" "2.0.0" - "@opentelemetry/sdk-metrics" "2.0.0" - -"@opentelemetry/exporter-trace-otlp-grpc@0.200.0": - version "0.200.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.200.0.tgz#e259367f324c01342bf3f0175c52d9f4e61a345f" - integrity sha512-hmeZrUkFl1YMsgukSuHCFPYeF9df0hHoKeHUthRKFCxiURs+GwF1VuabuHmBMZnjTbsuvNjOB+JSs37Csem/5Q== - dependencies: - "@grpc/grpc-js" "^1.7.1" - "@opentelemetry/core" "2.0.0" - "@opentelemetry/otlp-exporter-base" "0.200.0" - "@opentelemetry/otlp-grpc-exporter-base" "0.200.0" - "@opentelemetry/otlp-transformer" "0.200.0" - "@opentelemetry/resources" "2.0.0" - "@opentelemetry/sdk-trace-base" "2.0.0" - -"@opentelemetry/exporter-trace-otlp-http@0.200.0": - version "0.200.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.200.0.tgz#ddf2bbdff5157a89f64aad6dad44c394872d589d" - integrity sha512-Goi//m/7ZHeUedxTGVmEzH19NgqJY+Bzr6zXo1Rni1+hwqaksEyJ44gdlEMREu6dzX1DlAaH/qSykSVzdrdafA== - dependencies: - "@opentelemetry/core" "2.0.0" - "@opentelemetry/otlp-exporter-base" "0.200.0" - "@opentelemetry/otlp-transformer" "0.200.0" - "@opentelemetry/resources" "2.0.0" - "@opentelemetry/sdk-trace-base" "2.0.0" - -"@opentelemetry/exporter-trace-otlp-proto@0.200.0": - version "0.200.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.200.0.tgz#f3f149e6bad8c899c8f1e5c58e5d855ce07f7319" - integrity sha512-V9TDSD3PjK1OREw2iT9TUTzNYEVWJk4Nhodzhp9eiz4onDMYmPy3LaGbPv81yIR6dUb/hNp/SIhpiCHwFUq2Vg== - dependencies: - "@opentelemetry/core" "2.0.0" - "@opentelemetry/otlp-exporter-base" "0.200.0" - "@opentelemetry/otlp-transformer" "0.200.0" - "@opentelemetry/resources" "2.0.0" - "@opentelemetry/sdk-trace-base" "2.0.0" - -"@opentelemetry/exporter-zipkin@2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-zipkin/-/exporter-zipkin-2.0.0.tgz#6aca658d64f5e8bc079b07ee0a3076c4ca328ec9" - integrity sha512-icxaKZ+jZL/NHXX8Aru4HGsrdhK0MLcuRXkX5G5IRmCgoRLw+Br6I/nMVozX2xjGGwV7hw2g+4Slj8K7s4HbVg== - dependencies: - "@opentelemetry/core" "2.0.0" - "@opentelemetry/resources" "2.0.0" - "@opentelemetry/sdk-trace-base" "2.0.0" - "@opentelemetry/semantic-conventions" "^1.29.0" - "@opentelemetry/instrumentation-amqplib@0.60.0": version "0.60.0" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.60.0.tgz#a2b2abe3cf433bea166c18a703c8ddf6accf83da" @@ -6590,7 +6433,7 @@ "@opentelemetry/instrumentation" "^0.213.0" "@opentelemetry/semantic-conventions" "^1.24.0" -"@opentelemetry/instrumentation@0.200.0", "@opentelemetry/instrumentation@0.213.0", "@opentelemetry/instrumentation@^0.200.0", "@opentelemetry/instrumentation@^0.207.0", "@opentelemetry/instrumentation@^0.212.0", "@opentelemetry/instrumentation@^0.213.0": +"@opentelemetry/instrumentation@0.213.0", "@opentelemetry/instrumentation@^0.213.0": version "0.213.0" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.213.0.tgz#55362569efd0cba00aab9921a78dd20dfddf70b6" integrity sha512-3i9NdkET/KvQomeh7UaR/F4r9P25Rx6ooALlWXPIjypcEOUxksCmVu0zA70NBJWlrMW1rPr/LRidFAflLI+s/w== @@ -6599,57 +6442,30 @@ import-in-the-middle "^3.0.0" require-in-the-middle "^8.0.0" -"@opentelemetry/otlp-exporter-base@0.200.0": - version "0.200.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.200.0.tgz#906bcf2e59815c8ded732d328f6bc060fb7b0459" - integrity sha512-IxJgA3FD7q4V6gGq4bnmQM5nTIyMDkoGFGrBrrDjB6onEiq1pafma55V+bHvGYLWvcqbBbRfezr1GED88lacEQ== - dependencies: - "@opentelemetry/core" "2.0.0" - "@opentelemetry/otlp-transformer" "0.200.0" - -"@opentelemetry/otlp-grpc-exporter-base@0.200.0": - version "0.200.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.200.0.tgz#cfc6cfd4def7d47f84e43d438d75cb463c67bf0d" - integrity sha512-CK2S+bFgOZ66Bsu5hlDeOX6cvW5FVtVjFFbWuaJP0ELxJKBB6HlbLZQ2phqz/uLj1cWap5xJr/PsR3iGoB7Vqw== - dependencies: - "@grpc/grpc-js" "^1.7.1" - "@opentelemetry/core" "2.0.0" - "@opentelemetry/otlp-exporter-base" "0.200.0" - "@opentelemetry/otlp-transformer" "0.200.0" - -"@opentelemetry/otlp-transformer@0.200.0": - version "0.200.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-transformer/-/otlp-transformer-0.200.0.tgz#19afb2274554cb74e2d2b7e32a54a7f7d83c8642" - integrity sha512-+9YDZbYybOnv7sWzebWOeK6gKyt2XE7iarSyBFkwwnP559pEevKOUD8NyDHhRjCSp13ybh9iVXlMfcj/DwF/yw== - dependencies: - "@opentelemetry/api-logs" "0.200.0" - "@opentelemetry/core" "2.0.0" - "@opentelemetry/resources" "2.0.0" - "@opentelemetry/sdk-logs" "0.200.0" - "@opentelemetry/sdk-metrics" "2.0.0" - "@opentelemetry/sdk-trace-base" "2.0.0" - protobufjs "^7.3.0" - -"@opentelemetry/propagator-b3@2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/propagator-b3/-/propagator-b3-2.0.0.tgz#1b6244ef2d08a70672521a9aff56e485bd607c17" - integrity sha512-blx9S2EI49Ycuw6VZq+bkpaIoiJFhsDuvFGhBIoH3vJ5oYjJ2U0s3fAM5jYft99xVIAv6HqoPtlP9gpVA2IZtA== +"@opentelemetry/instrumentation@^0.207.0": + version "0.207.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.207.0.tgz#1a5a921c04f171ff28096fa320af713f3c87ec14" + integrity sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA== dependencies: - "@opentelemetry/core" "2.0.0" + "@opentelemetry/api-logs" "0.207.0" + import-in-the-middle "^2.0.0" + require-in-the-middle "^8.0.0" -"@opentelemetry/propagator-jaeger@2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/propagator-jaeger/-/propagator-jaeger-2.0.0.tgz#288d6767dea554db684fd5e144ad8653d83fd2ea" - integrity sha512-Mbm/LSFyAtQKP0AQah4AfGgsD+vsZcyreZoQ5okFBk33hU7AquU4TltgyL9dvaO8/Zkoud8/0gEvwfOZ5d7EPA== +"@opentelemetry/instrumentation@^0.212.0": + version "0.212.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.212.0.tgz#238b6e3e2131217ff4acfe7e8e7b6ce1f0ac0ba0" + integrity sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg== dependencies: - "@opentelemetry/core" "2.0.0" + "@opentelemetry/api-logs" "0.212.0" + import-in-the-middle "^2.0.6" + 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.0.0", "@opentelemetry/resources@2.6.0", "@opentelemetry/resources@^2.6.0": +"@opentelemetry/resources@2.6.0", "@opentelemetry/resources@^2.6.0": version "2.6.0" resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-2.6.0.tgz#1a945dbb8986043d8b593c358d5d8e3de6becf5a" integrity sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ== @@ -6657,52 +6473,7 @@ "@opentelemetry/core" "2.6.0" "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/sdk-logs@0.200.0": - version "0.200.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-logs/-/sdk-logs-0.200.0.tgz#893d86cefa6f2c02a7cd03d5cb4a959eed3653d1" - integrity sha512-VZG870063NLfObmQQNtCVcdXXLzI3vOjjrRENmU37HYiPFa0ZXpXVDsTD02Nh3AT3xYJzQaWKl2X2lQ2l7TWJA== - dependencies: - "@opentelemetry/api-logs" "0.200.0" - "@opentelemetry/core" "2.0.0" - "@opentelemetry/resources" "2.0.0" - -"@opentelemetry/sdk-metrics@2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.0.tgz#aba86060bc363c661ca286339c5b04590e298b69" - integrity sha512-Bvy8QDjO05umd0+j+gDeWcTaVa1/R2lDj/eOvjzpm8VQj1K1vVZJuyjThpV5/lSHyYW2JaHF2IQ7Z8twJFAhjA== - dependencies: - "@opentelemetry/core" "2.0.0" - "@opentelemetry/resources" "2.0.0" - -"@opentelemetry/sdk-node@^0.200.0": - version "0.200.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-node/-/sdk-node-0.200.0.tgz#033d0641da628f1537cf7442f41cd77c048923ae" - integrity sha512-S/YSy9GIswnhYoDor1RusNkmRughipvTCOQrlF1dzI70yQaf68qgf5WMnzUxdlCl3/et/pvaO75xfPfuEmCK5A== - dependencies: - "@opentelemetry/api-logs" "0.200.0" - "@opentelemetry/core" "2.0.0" - "@opentelemetry/exporter-logs-otlp-grpc" "0.200.0" - "@opentelemetry/exporter-logs-otlp-http" "0.200.0" - "@opentelemetry/exporter-logs-otlp-proto" "0.200.0" - "@opentelemetry/exporter-metrics-otlp-grpc" "0.200.0" - "@opentelemetry/exporter-metrics-otlp-http" "0.200.0" - "@opentelemetry/exporter-metrics-otlp-proto" "0.200.0" - "@opentelemetry/exporter-prometheus" "0.200.0" - "@opentelemetry/exporter-trace-otlp-grpc" "0.200.0" - "@opentelemetry/exporter-trace-otlp-http" "0.200.0" - "@opentelemetry/exporter-trace-otlp-proto" "0.200.0" - "@opentelemetry/exporter-zipkin" "2.0.0" - "@opentelemetry/instrumentation" "0.200.0" - "@opentelemetry/propagator-b3" "2.0.0" - "@opentelemetry/propagator-jaeger" "2.0.0" - "@opentelemetry/resources" "2.0.0" - "@opentelemetry/sdk-logs" "0.200.0" - "@opentelemetry/sdk-metrics" "2.0.0" - "@opentelemetry/sdk-trace-base" "2.0.0" - "@opentelemetry/sdk-trace-node" "2.0.0" - "@opentelemetry/semantic-conventions" "^1.29.0" - -"@opentelemetry/sdk-trace-base@2.0.0", "@opentelemetry/sdk-trace-base@2.6.0", "@opentelemetry/sdk-trace-base@^2.6.0": +"@opentelemetry/sdk-trace-base@^2.6.0": version "2.6.0" resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.0.tgz#d7e752a0906f2bcae3c1261e224aef3e3b3746f9" integrity sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ== @@ -6711,15 +6482,6 @@ "@opentelemetry/resources" "2.6.0" "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/sdk-trace-node@2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.0.0.tgz#ef9f8ab77ccb41a9c9ff272f6bf4bb6999491f5b" - integrity sha512-omdilCZozUjQwY3uZRBwbaRMJ3p09l4t187Lsdf0dGMye9WKD4NGcpgZRvqhI1dwcH6og+YXQEtoO9Wx3ykilg== - dependencies: - "@opentelemetry/context-async-hooks" "2.0.0" - "@opentelemetry/core" "2.0.0" - "@opentelemetry/sdk-trace-base" "2.0.0" - "@opentelemetry/semantic-conventions@^1.24.0", "@opentelemetry/semantic-conventions@^1.27.0", "@opentelemetry/semantic-conventions@^1.28.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.40.0": version "1.40.0" resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz#10b2944ca559386590683392022a897eefd011d3" @@ -9987,7 +9749,7 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@>=10.0.0", "@types/node@>=13.7.0", "@types/node@>=18": +"@types/node@*", "@types/node@>=10.0.0", "@types/node@>=18": version "25.4.0" resolved "https://registry.yarnpkg.com/@types/node/-/node-25.4.0.tgz#f25d8467984d6667cc4c1be1e2f79593834aaedb" integrity sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw== @@ -19499,6 +19261,16 @@ import-fresh@^3.2.1: parent-module "^1.0.0" resolve-from "^4.0.0" +import-in-the-middle@^2.0.0, import-in-the-middle@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz#1972337bfe020d05f6b5e020c13334567436324f" + integrity sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw== + dependencies: + acorn "^8.15.0" + acorn-import-attributes "^1.9.5" + cjs-module-lexer "^2.2.0" + module-details-from-path "^1.0.4" + import-in-the-middle@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/import-in-the-middle/-/import-in-the-middle-3.0.0.tgz#720c12b4c07ea58b32a54667e70a022e18cc36a3" @@ -21201,7 +20973,7 @@ lodash.assign@^3.2.0: lodash._createassigner "^3.0.0" lodash.keys "^3.0.0" -lodash.camelcase@^4.1.1, lodash.camelcase@^4.3.0: +lodash.camelcase@^4.1.1: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" integrity sha1-soqmKIorn8ZRA1x3EfZathkDMaY= @@ -21415,7 +21187,7 @@ long@^4.0.0: resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA== -long@^5.0.0, long@^5.3.2: +long@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== @@ -25828,24 +25600,6 @@ property-information@^7.0.0: resolved "https://registry.yarnpkg.com/property-information/-/property-information-7.1.0.tgz#b622e8646e02b580205415586b40804d3e8bfd5d" integrity sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ== -protobufjs@^7.3.0, protobufjs@^7.5.3: - version "7.5.4" - resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.5.4.tgz#885d31fe9c4b37f25d1bb600da30b1c5b37d286a" - integrity sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg== - dependencies: - "@protobufjs/aspromise" "^1.1.2" - "@protobufjs/base64" "^1.1.2" - "@protobufjs/codegen" "^2.0.4" - "@protobufjs/eventemitter" "^1.1.0" - "@protobufjs/fetch" "^1.1.0" - "@protobufjs/float" "^1.0.2" - "@protobufjs/inquire" "^1.1.0" - "@protobufjs/path" "^1.1.2" - "@protobufjs/pool" "^1.1.0" - "@protobufjs/utf8" "^1.1.0" - "@types/node" ">=13.7.0" - long "^5.0.0" - proxy-addr@^2.0.7, proxy-addr@~2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" @@ -31589,7 +31343,7 @@ yargs@^16.1.1, yargs@^16.2.0: y18n "^5.0.5" yargs-parser "^20.2.2" -yargs@^17.2.1, yargs@^17.5.1, yargs@^17.6.0, yargs@^17.6.2, yargs@^17.7.2: +yargs@^17.2.1, yargs@^17.5.1, yargs@^17.6.0, yargs@^17.6.2: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== From 6f48cc4e804132c4a5c422899ab36a8da5e6f00f Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 25 Mar 2026 13:42:31 +0100 Subject: [PATCH 43/43] meta(changelog): Update changelog for 10.46.0 Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a6760d5d1ec..640448521586 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,73 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.46.0 + +### Important Changes + +- **feat(elysia): `@sentry/elysia` - Alpha Release ([#19509](https://github.com/getsentry/sentry-javascript/pull/19509))** + + New Sentry SDK for the [Elysia](https://elysiajs.com/) web framework, supporting both Bun and Node.js runtimes. + + > **Note:** This is an alpha release. Please report any issues or feedback on [GitHub](https://github.com/getsentry/sentry-javascript/issues). + + **Features** + - **Automatic error capturing** — 5xx errors captured via global `onError` hook; 3xx/4xx ignored by default. Customizable with `shouldHandleError`. + - **Automatic tracing** — Lifecycle spans for every Elysia phase (Request, Parse, Transform, BeforeHandle, Handle, AfterHandle, MapResponse, AfterResponse, Error) with parameterized route names (e.g. `GET /users/:id`). + - **Distributed tracing** — `sentry-trace` and `baggage` headers propagated automatically on incoming/outgoing requests. + + **Usage** + + ```javascript + import * as Sentry from '@sentry/elysia'; + import { Elysia } from 'elysia'; + + Sentry.init({ dsn: '__DSN__', tracesSampleRate: 1.0 }); + + const app = Sentry.withElysia(new Elysia()); + app.get('/', () => 'Hello World'); + app.listen(3000); + ``` + +### Other Changes + +- feat(nuxt): Conditionally use plugins based on Nitro version (v2/v3) ([#19955](https://github.com/getsentry/sentry-javascript/pull/19955)) +- fix(cloudflare): Forward `ctx` argument to `Workflow.do` user callback ([#19891](https://github.com/getsentry/sentry-javascript/pull/19891)) +- fix(cloudflare): Send correct events in local development ([#19900](https://github.com/getsentry/sentry-javascript/pull/19900)) +- fix(core): Do not overwrite user provided conversation id in Vercel ([#19903](https://github.com/getsentry/sentry-javascript/pull/19903)) +- fix(core): Preserve `.withResponse()` on Anthropic instrumentation ([#19935](https://github.com/getsentry/sentry-javascript/pull/19935)) +- fix(core): Send `internal_error` as span status for Vercel error spans ([#19921](https://github.com/getsentry/sentry-javascript/pull/19921)) +- fix(core): Truncate content array format in Vercel ([#19911](https://github.com/getsentry/sentry-javascript/pull/19911)) +- fix(deps): bump fast-xml-parser to 5.5.8 in @azure/core-xml chain ([#19918](https://github.com/getsentry/sentry-javascript/pull/19918)) +- fix(deps): bump socket.io-parser to 4.2.6 to fix CVE-2026-33151 ([#19880](https://github.com/getsentry/sentry-javascript/pull/19880)) +- fix(nestjs): Add `node` to nest metadata ([#19875](https://github.com/getsentry/sentry-javascript/pull/19875)) +- fix(serverless): Add node to metadata ([#19878](https://github.com/getsentry/sentry-javascript/pull/19878)) + +
+ Internal Changes + +- chore(ci): Fix "Gatbsy" typo in issue package label workflow ([#19905](https://github.com/getsentry/sentry-javascript/pull/19905)) +- chore(claude): Enable Claude Code Intelligence (LSP) ([#19930](https://github.com/getsentry/sentry-javascript/pull/19930)) +- chore(deps): bump mongodb-memory-server-global from 10.1.4 to 11.0.1 ([#19888](https://github.com/getsentry/sentry-javascript/pull/19888)) +- chore(deps-dev): bump @react-router/node from 7.13.0 to 7.13.1 ([#19544](https://github.com/getsentry/sentry-javascript/pull/19544)) +- chore(deps-dev): bump effect from 3.19.19 to 3.20.0 ([#19926](https://github.com/getsentry/sentry-javascript/pull/19926)) +- chore(deps-dev): bump qunit-dom from 3.2.1 to 3.5.0 ([#19546](https://github.com/getsentry/sentry-javascript/pull/19546)) +- chore(node-integration-tests): Remove unnecessary `file-type` dependency ([#19824](https://github.com/getsentry/sentry-javascript/pull/19824)) +- chore(remix): Replace glob with native recursive fs walk ([#19531](https://github.com/getsentry/sentry-javascript/pull/19531)) +- feat(deps): bump stacktrace-parser from 0.1.10 to 0.1.11 ([#19887](https://github.com/getsentry/sentry-javascript/pull/19887)) +- fix(craft): Add missing mainDocsUrl for @sentry/effect SDK ([#19860](https://github.com/getsentry/sentry-javascript/pull/19860)) +- fix(deps): bump next to 15.5.14 in nextjs-15 and nextjs-15-intl E2E test apps ([#19917](https://github.com/getsentry/sentry-javascript/pull/19917)) +- fix(deps): update lockfile to resolve h3@1.15.10 ([#19933](https://github.com/getsentry/sentry-javascript/pull/19933)) +- ref(core): Remove duplicate `buildMethodPath` utility from openai ([#19969](https://github.com/getsentry/sentry-javascript/pull/19969)) +- ref(elysia): Drop `@elysiajs/opentelemetry` dependency ([#19947](https://github.com/getsentry/sentry-javascript/pull/19947)) +- ref(nuxt): Extract core logic for storage/database to prepare for Nuxt v5 ([#19920](https://github.com/getsentry/sentry-javascript/pull/19920)) +- ref(nuxt): Extract handler patching to extra plugin for Nitro v2/v3 ([#19915](https://github.com/getsentry/sentry-javascript/pull/19915)) +- ref(sveltekit): Replace recast + @babel/parser with acorn ([#19533](https://github.com/getsentry/sentry-javascript/pull/19533)) +- test(astro): Re-enable server island tracing e2e test in Astro 6 ([#19872](https://github.com/getsentry/sentry-javascript/pull/19872)) +- test(cloudflare): Enable multi-worker tests for CF integration tests ([#19938](https://github.com/getsentry/sentry-javascript/pull/19938)) + +
+ Work in this release was contributed by @roli-lpci. Thank you for your contributions! ## 10.45.0