From 64ef5534805829ea35f7fca7fb0620db4d204027 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 30 Sep 2025 17:01:13 +0300 Subject: [PATCH 01/15] fix(nuxt): include `sentry.client.config.ts` in nuxt app types (#17830) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # What Fixes #17781 by adding the `sentry.config.client.ts` to the auto generated tsconfig by extending the types via the [`prepare:types` hook](https://nuxt.com/docs/4.x/guide/going-further/modules#adding-type-declarations). This allows `useRuntimeConfig` to be properly typed in the root `sentry.client.config.ts`, or where ever the client file is found. Not sure how this could be tested tho, since it is purely types. --- > [!NOTE] > Adds a `prepare:types` hook to include the client Sentry config in Nuxt’s generated `tsconfig` for proper typing. > > - **Nuxt module (`packages/nuxt/src/module.ts`)**: > - **Types setup**: Add `prepare:types` hook to ensure `tsconfig.include` contains the relative path to the detected client Sentry config (`sentry.client.config.ts`), enabling proper typing (e.g., `useRuntimeConfig`) in root config files for Nuxt v4. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 4de6d439e42daa976492a95a5778772f747bf32b. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- packages/nuxt/src/module.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 7e9445a154a7..1e806e4dc2eb 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -72,6 +72,18 @@ export default defineNuxtModule({ mode: 'client', order: 1, }); + + // Add the sentry config file to the include array + nuxt.hook('prepare:types', options => { + if (!options.tsConfig.include) { + options.tsConfig.include = []; + } + + // Add type references for useRuntimeConfig in root files for nuxt v4 + // Should be relative to `root/.nuxt` + const relativePath = path.relative(nuxt.options.buildDir, clientConfigFile); + options.tsConfig.include.push(relativePath); + }); } const serverConfigFile = findDefaultSdkInitFile('server', nuxt); From 5cfa1c19f3e9c84a32285c6fd3abf5478d50f6f9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 11:49:06 +0200 Subject: [PATCH 02/15] chore(deps): Bump @sentry/cli from 2.53.0 to 2.56.0 (#17819) --- packages/react-router/package.json | 2 +- packages/remix/package.json | 2 +- yarn.lock | 111 +++++++++++++++-------------- 3 files changed, 58 insertions(+), 57 deletions(-) diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 1b41ba966a1b..328551df5fd1 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -50,7 +50,7 @@ "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@sentry/browser": "10.17.0", - "@sentry/cli": "^2.53.0", + "@sentry/cli": "^2.56.0", "@sentry/core": "10.17.0", "@sentry/node": "10.17.0", "@sentry/react": "10.17.0", diff --git a/packages/remix/package.json b/packages/remix/package.json index 67c687ef0405..66c1b859565a 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -68,7 +68,7 @@ "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@remix-run/router": "1.x", - "@sentry/cli": "^2.53.0", + "@sentry/cli": "^2.56.0", "@sentry/core": "10.17.0", "@sentry/node": "10.17.0", "@sentry/react": "10.17.0", diff --git a/yarn.lock b/yarn.lock index 8c030924b033..e9381b1c735a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7007,50 +7007,50 @@ magic-string "0.30.8" unplugin "1.0.1" -"@sentry/cli-darwin@2.53.0": - version "2.53.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.53.0.tgz#0584f5a4a376c9373f91ad5e1d9194278be2aed6" - integrity sha512-NNPfpILMwKgpHiyJubHHuauMKltkrgLQ5tvMdxNpxY60jBNdo5VJtpESp4XmXlnidzV4j1z61V4ozU6ttDgt5Q== - -"@sentry/cli-linux-arm64@2.53.0": - version "2.53.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.53.0.tgz#04a73b2592edf10d6e06957905becc98692605b1" - integrity sha512-xY/CZ1dVazsSCvTXzKpAgXaRqfljVfdrFaYZRUaRPf1ZJRGa3dcrivoOhSIeG/p5NdYtMvslMPY9Gm2MT0M83A== - -"@sentry/cli-linux-arm@2.53.0": - version "2.53.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-2.53.0.tgz#caa1dceb23ee40e9d0c82a7c6156c3f010eebc0e" - integrity sha512-NdRzQ15Ht83qG0/Lyu11ciy/Hu/oXbbtJUgwzACc7bWvHQA8xEwTsehWexqn1529Kfc5EjuZ0Wmj3MHmp+jOWw== - -"@sentry/cli-linux-i686@2.53.0": - version "2.53.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-2.53.0.tgz#989dc766b098e94c6751bad3efcd4ca0fe1a2565" - integrity sha512-0REmBibGAB4jtqt9S6JEsFF4QybzcXHPcHtJjgMi5T0ueh952uG9wLzjSxQErCsxTKF+fL8oG0Oz5yKBuCwCCQ== - -"@sentry/cli-linux-x64@2.53.0": - version "2.53.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-2.53.0.tgz#2a94361233ed24e4a32f08919011a591aea4cb6b" - integrity sha512-9UGJL+Vy5N/YL1EWPZ/dyXLkShlNaDNrzxx4G7mTS9ywjg+BIuemo6rnN7w43K1NOjObTVO6zY0FwumJ1pCyLg== - -"@sentry/cli-win32-arm64@2.53.0": - version "2.53.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.53.0.tgz#946609eabd318657521c4b3ef15a420cc00f1c60" - integrity sha512-G1kjOjrjMBY20rQcJV2GA8KQE74ufmROCDb2GXYRfjvb1fKAsm4Oh8N5+Tqi7xEHdjQoLPkE4CNW0aH68JSUDQ== - -"@sentry/cli-win32-i686@2.53.0": - version "2.53.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-2.53.0.tgz#f51937d73cefad16b9d2e89acc4c9f178da36cc6" - integrity sha512-qbGTZUzesuUaPtY9rPXdNfwLqOZKXrJRC1zUFn52hdo6B+Dmv0m/AHwRVFHZP53Tg1NCa8bDei2K/uzRN0dUZw== - -"@sentry/cli-win32-x64@2.53.0": - version "2.53.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.53.0.tgz#d89cde8354b4eb8e89f2c11dc6a6fb5e7392e2ae" - integrity sha512-1TXYxYHtwgUq5KAJt3erRzzUtPqg7BlH9T7MdSPHjJatkrr/kwZqnVe2H6Arr/5NH891vOlIeSPHBdgJUAD69g== - -"@sentry/cli@^2.51.0", "@sentry/cli@^2.53.0": - version "2.53.0" - resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.53.0.tgz#fd5b65b9f6f06f0ed16345acf3ecf0720bd7bcf8" - integrity sha512-n2ZNb+5Z6AZKQSI0SusQ7ZzFL637mfw3Xh4C3PEyVSn9LiF683fX0TTq8OeGmNZQS4maYfS95IFD+XpydU0dEA== +"@sentry/cli-darwin@2.56.0": + version "2.56.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.56.0.tgz#53fa7de2c26f6450d5454ba997c26c2471d112c8" + integrity sha512-CzXFWbv3GrjU0gFlUM9jt0fvJmyo5ktty4HGxRFfS/eMC6xW58Gg/sEeMVEkdvk5osKooX/YEgfLBdo4zvuWDA== + +"@sentry/cli-linux-arm64@2.56.0": + version "2.56.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.56.0.tgz#5041c8877416a607ddae87b948cbe6c9e86d7f54" + integrity sha512-91d5ZlC989j/t+TXor/glPyx6SnLFS/SlJ9fIrHIQohdGKyWWSFb4VKUan8Ok3GYu9SUzKTMByryIOoYEmeGVw== + +"@sentry/cli-linux-arm@2.56.0": + version "2.56.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-2.56.0.tgz#c7875cf5f76e254ff1c0f49cf99d8c26b6ec4959" + integrity sha512-vQCCMhZLugPmr25XBoP94dpQsFa110qK5SBUVJcRpJKyzMZd+6ueeHNslq2mB0OF4BwL1qd/ZDIa4nxa1+0rjQ== + +"@sentry/cli-linux-i686@2.56.0": + version "2.56.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-2.56.0.tgz#aeaff32f9f0d405e413373223e406d66b1d56176" + integrity sha512-MZzXuq1Q/TktN81DUs6XSBU752pG3XWSJdZR+NCStIg3l8s3O/Pwh6OcDHTYqgwsYJaGBpA0fP2Afl5XeSAUNg== + +"@sentry/cli-linux-x64@2.56.0": + version "2.56.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-2.56.0.tgz#3dd4ef83c2d710c3e6f5d078d05391fda2ce23ee" + integrity sha512-INOO2OQ90Y3UzYgHRdrHdKC/0es3YSHLv0iNNgQwllL0YZihSVNYSSrZqcPq8oSDllEy9Vt9oOm/7qEnUP2Kfw== + +"@sentry/cli-win32-arm64@2.56.0": + version "2.56.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.56.0.tgz#2113bcac721970ca4dbd04a6dab37dfb0ec147d2" + integrity sha512-eUvkVk9KK01q6/qyugQPh7dAxqFPbgOa62QAoSwo11WQFYc3NPgJLilFWLQo+nahHGYKh6PKuCJ5tcqnQq5Hkg== + +"@sentry/cli-win32-i686@2.56.0": + version "2.56.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-2.56.0.tgz#bd8e646f4b5a98aa80bc9751a6e0db6514a935f5" + integrity sha512-mpCA8hKXuvT17bl1H/54KOa5i+02VBBHVlOiP3ltyBuQUqfvX/30Zl/86Spy+ikodovZWAHv5e5FpyXbY1/mPw== + +"@sentry/cli-win32-x64@2.56.0": + version "2.56.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.56.0.tgz#1acc7ca166ed531075a31b2bc1700294747da6b8" + integrity sha512-UV0pXNls+/ViAU/3XsHLLNEHCsRYaGEwJdY3HyGIufSlglxrX6BVApkV9ziGi4WAxcJWLjQdfcEs6V5B+wBy0A== + +"@sentry/cli@^2.51.0", "@sentry/cli@^2.56.0": + version "2.56.0" + resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.56.0.tgz#13dc043c78687b47285cc45db5bcfb65bbdb6dd9" + integrity sha512-br6+1nTPUV5EG1oaxLzxv31kREFKr49Y1+3jutfMUz9Nl8VyVP7o9YwakB/YWl+0Vi0NXg5vq7qsd/OOuV5j8w== dependencies: https-proxy-agent "^5.0.0" node-fetch "^2.6.7" @@ -7058,14 +7058,14 @@ proxy-from-env "^1.1.0" which "^2.0.2" optionalDependencies: - "@sentry/cli-darwin" "2.53.0" - "@sentry/cli-linux-arm" "2.53.0" - "@sentry/cli-linux-arm64" "2.53.0" - "@sentry/cli-linux-i686" "2.53.0" - "@sentry/cli-linux-x64" "2.53.0" - "@sentry/cli-win32-arm64" "2.53.0" - "@sentry/cli-win32-i686" "2.53.0" - "@sentry/cli-win32-x64" "2.53.0" + "@sentry/cli-darwin" "2.56.0" + "@sentry/cli-linux-arm" "2.56.0" + "@sentry/cli-linux-arm64" "2.56.0" + "@sentry/cli-linux-i686" "2.56.0" + "@sentry/cli-linux-x64" "2.56.0" + "@sentry/cli-win32-arm64" "2.56.0" + "@sentry/cli-win32-i686" "2.56.0" + "@sentry/cli-win32-x64" "2.56.0" "@sentry/rollup-plugin@^4.3.0": version "4.3.0" @@ -28556,7 +28556,7 @@ string-template@~0.2.1: string-width@4.2.3, "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== dependencies: emoji-regex "^8.0.0" @@ -28666,7 +28666,7 @@ stringify-object@^3.2.1: strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== dependencies: ansi-regex "^5.0.1" @@ -28843,6 +28843,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" @@ -31735,7 +31736,7 @@ wrangler@4.22.0: wrap-ansi@7.0.0, wrap-ansi@^7.0.0: version "7.0.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== dependencies: ansi-styles "^4.0.0" From ec2337a957c44dc83ac0d638af577d32710854df Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 1 Oct 2025 14:21:41 +0200 Subject: [PATCH 03/15] chore(deps): Bump axios in browser integration tests (#17839) Not a big issue since we only use this internally closes https://github.com/getsentry/sentry-javascript/security/dependabot/757 --- dev-packages/browser-integration-tests/package.json | 2 +- yarn.lock | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 969f4bbb1e46..2ac5163bc855 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -45,7 +45,7 @@ "@sentry-internal/rrweb": "2.34.0", "@sentry/browser": "10.17.0", "@supabase/supabase-js": "2.49.3", - "axios": "1.8.2", + "axios": "^1.12.2", "babel-loader": "^8.2.2", "fflate": "0.8.2", "html-webpack-plugin": "^5.5.0", diff --git a/yarn.lock b/yarn.lock index e9381b1c735a..677f2320769b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10956,13 +10956,13 @@ aws-ssl-profiles@^1.1.1: resolved "https://registry.yarnpkg.com/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz#157dd77e9f19b1d123678e93f120e6f193022641" integrity sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g== -axios@1.8.2, axios@^1.0.0: - version "1.8.2" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.8.2.tgz#fabe06e241dfe83071d4edfbcaa7b1c3a40f7979" - integrity sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg== +axios@^1.0.0, axios@^1.12.2: + version "1.12.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.12.2.tgz#6c307390136cf7a2278d09cec63b136dfc6e6da7" + integrity sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw== dependencies: follow-redirects "^1.15.6" - form-data "^4.0.0" + form-data "^4.0.4" proxy-from-env "^1.1.0" axobject-query@^3.2.1: @@ -17213,7 +17213,7 @@ foreground-child@^3.1.0: cross-spawn "^7.0.6" signal-exit "^4.0.1" -form-data@^4.0.0: +form-data@^4.0.0, form-data@^4.0.4: version "4.0.4" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.4.tgz#784cdcce0669a9d68e94d11ac4eea98088edd2c4" integrity sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow== @@ -28843,7 +28843,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 0295e695fef5bfb3c767466f298f92329644ac7c Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 1 Oct 2025 14:21:52 +0200 Subject: [PATCH 04/15] chore(deps): Bump nestjs in integration tests (#17840) ref https://github.com/getsentry/sentry-javascript/security/dependabot/648 Also makes sense to run these on latest I'd say. --- .../node-core-integration-tests/package.json | 6 +- .../node-integration-tests/package.json | 6 +- yarn.lock | 186 ++++++------------ 3 files changed, 69 insertions(+), 129 deletions(-) diff --git a/dev-packages/node-core-integration-tests/package.json b/dev-packages/node-core-integration-tests/package.json index eb6161cb9a80..5cec961c736a 100644 --- a/dev-packages/node-core-integration-tests/package.json +++ b/dev-packages/node-core-integration-tests/package.json @@ -23,9 +23,9 @@ "test:watch": "yarn test --watch" }, "dependencies": { - "@nestjs/common": "11.0.16", - "@nestjs/core": "10.4.6", - "@nestjs/platform-express": "10.4.6", + "@nestjs/common": "^11", + "@nestjs/core": "^11", + "@nestjs/platform-express": "^11", "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^2.1.0", "@opentelemetry/core": "^2.1.0", diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index dbf8d9001ed4..48073f2ac817 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -28,9 +28,9 @@ "@google/genai": "^1.20.0", "@hapi/hapi": "^21.3.10", "@hono/node-server": "^1.19.4", - "@nestjs/common": "11.1.3", - "@nestjs/core": "11.1.3", - "@nestjs/platform-express": "11.1.3", + "@nestjs/common": "^11", + "@nestjs/core": "^11", + "@nestjs/platform-express": "^11", "@prisma/client": "6.15.0", "@sentry/aws-serverless": "10.17.0", "@sentry/core": "10.17.0", diff --git a/yarn.lock b/yarn.lock index 677f2320769b..3a6b6375c015 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4958,26 +4958,6 @@ dependencies: sparse-bitfield "^3.0.3" -"@nestjs/common@11.0.16": - version "11.0.16" - resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-11.0.16.tgz#b6550ac2998e9991f24a99563a93475542885ba7" - integrity sha512-agvuQ8su4aZ+PVxAmY89odG1eR97HEQvxPmTMdDqyvDWzNerl7WQhUEd+j4/UyNWcF1or1UVcrtPj52x+eUSsA== - dependencies: - uid "2.0.2" - iterare "1.2.1" - tslib "2.8.1" - -"@nestjs/common@11.1.3": - version "11.1.3" - resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-11.1.3.tgz#d954644da5f4d1b601e48ee71a0d3e3405d81ea1" - integrity sha512-ogEK+GriWodIwCw6buQ1rpcH4Kx+G7YQ9EwuPySI3rS05pSdtQ++UhucjusSI9apNidv+QURBztJkRecwwJQXg== - dependencies: - uid "2.0.2" - file-type "21.0.0" - iterare "1.2.1" - load-esm "1.0.2" - tslib "2.8.1" - "@nestjs/common@^10.0.0": version "10.4.15" resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.4.15.tgz#27c291466d9100eb86fdbe6f7bbb4d1a6ad55f70" @@ -4987,28 +4967,15 @@ iterare "1.2.1" tslib "2.8.1" -"@nestjs/core@10.4.6": - version "10.4.6" - resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-10.4.6.tgz#797b381f12bd62d2e425897058fa219da4c3689d" - integrity sha512-zXVPxCNRfO6gAy0yvEDjUxE/8gfZICJFpsl2lZAUH31bPb6m+tXuhUq2mVCTEltyMYQ+DYtRe+fEYM2v152N1g== - dependencies: - uid "2.0.2" - "@nuxtjs/opencollective" "0.3.2" - fast-safe-stringify "2.1.1" - iterare "1.2.1" - path-to-regexp "3.3.0" - tslib "2.7.0" - -"@nestjs/core@11.1.3": - version "11.1.3" - resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-11.1.3.tgz#42a9c6261ff70ef49afa809c526134cae22021e8" - integrity sha512-5lTni0TCh8x7bXETRD57pQFnKnEg1T6M+VLE7wAmyQRIecKQU+2inRGZD+A4v2DC1I04eA0WffP0GKLxjOKlzw== +"@nestjs/common@^11": + version "11.1.6" + resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-11.1.6.tgz#704ae26f09ccd135bf3e6f44b6ef4e3407ea3c54" + integrity sha512-krKwLLcFmeuKDqngG2N/RuZHCs2ycsKcxWIDgcm7i1lf3sQ0iG03ci+DsP/r3FcT/eJDFsIHnKtNta2LIi7PzQ== dependencies: uid "2.0.2" - "@nuxt/opencollective" "0.4.1" - fast-safe-stringify "2.1.1" + file-type "21.0.0" iterare "1.2.1" - path-to-regexp "8.2.0" + load-esm "1.0.2" tslib "2.8.1" "@nestjs/core@^10.0.0": @@ -5023,25 +4990,26 @@ path-to-regexp "3.3.0" tslib "2.8.1" -"@nestjs/platform-express@10.4.6": - version "10.4.6" - resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-10.4.6.tgz#6c39c522fa66036b4256714fea203fbeb49fc4de" - integrity sha512-HcyCpAKccAasrLSGRTGWv5BKRs0rwTIFOSsk6laNyqfqvgvYcJQAedarnm4jmaemtmSJ0PFI9PmtEZADd2ahCg== +"@nestjs/core@^11": + version "11.1.6" + resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-11.1.6.tgz#9d54882f121168b2fa2b07fa1db0858161a80626" + integrity sha512-siWX7UDgErisW18VTeJA+x+/tpNZrJewjTBsRPF3JVxuWRuAB1kRoiJcxHgln8Lb5UY9NdvklITR84DUEXD0Cg== dependencies: - body-parser "1.20.3" - cors "2.8.5" - express "4.21.1" - multer "1.4.4-lts.1" - tslib "2.7.0" + uid "2.0.2" + "@nuxt/opencollective" "0.4.1" + fast-safe-stringify "2.1.1" + iterare "1.2.1" + path-to-regexp "8.2.0" + tslib "2.8.1" -"@nestjs/platform-express@11.1.3": - version "11.1.3" - resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-11.1.3.tgz#bf470f2e270ca9daa930974476dd0d7d62879556" - integrity sha512-hEDNMlaPiBO72fxxX/CuRQL3MEhKRc/sIYGVoXjrnw6hTxZdezvvM6A95UaLsYknfmcZZa/CdG1SMBZOu9agHQ== +"@nestjs/platform-express@^11": + version "11.1.6" + resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-11.1.6.tgz#9b1dcf82a3b3fdd5761c918ad664aff83e4eacc7" + integrity sha512-HErwPmKnk+loTq8qzu1up+k7FC6Kqa8x6lJ4cDw77KnTxLzsCaPt+jBvOq6UfICmfqcqCCf3dKXg+aObQp+kIQ== dependencies: cors "2.8.5" express "5.1.0" - multer "2.0.1" + multer "2.0.2" path-to-regexp "8.2.0" tslib "2.8.1" @@ -12132,7 +12100,7 @@ bundle-name@^4.1.0: dependencies: run-applescript "^7.0.0" -busboy@1.6.0, busboy@^1.0.0, busboy@^1.6.0: +busboy@1.6.0, busboy@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== @@ -13052,16 +13020,6 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= -concat-stream@^1.5.2: - version "1.6.2" - resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" - integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== - dependencies: - buffer-from "^1.0.0" - inherits "^2.0.3" - readable-stream "^2.2.2" - typedarray "^0.0.6" - concat-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-2.0.0.tgz#414cf5af790a48c60ab9be4527d56d5e41133cb1" @@ -16512,7 +16470,40 @@ expect-type@^1.2.1: resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.2.1.tgz#af76d8b357cf5fa76c41c09dafb79c549e75f71f" integrity sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw== -express@4.21.1, express@^4.10.7, express@^4.17.1, express@^4.17.3, express@^4.18.1, express@^4.21.1: +express@5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/express/-/express-5.1.0.tgz#d31beaf715a0016f0d53f47d3b4d7acf28c75cc9" + integrity sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA== + dependencies: + accepts "^2.0.0" + body-parser "^2.2.0" + content-disposition "^1.0.0" + content-type "^1.0.5" + cookie "^0.7.1" + cookie-signature "^1.2.1" + debug "^4.4.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + etag "^1.8.1" + finalhandler "^2.1.0" + fresh "^2.0.0" + http-errors "^2.0.0" + merge-descriptors "^2.0.0" + mime-types "^3.0.0" + on-finished "^2.4.1" + once "^1.4.0" + parseurl "^1.3.3" + proxy-addr "^2.0.7" + qs "^6.14.0" + range-parser "^1.2.1" + router "^2.2.0" + send "^1.1.0" + serve-static "^2.2.0" + statuses "^2.0.1" + type-is "^2.0.1" + vary "^1.1.2" + +express@^4.10.7, express@^4.17.1, express@^4.17.3, express@^4.18.1, express@^4.21.1: version "4.21.1" resolved "https://registry.yarnpkg.com/express/-/express-4.21.1.tgz#9dae5dda832f16b4eec941a4e44aa89ec481b281" integrity sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ== @@ -16549,39 +16540,6 @@ express@4.21.1, express@^4.10.7, express@^4.17.1, express@^4.17.3, express@^4.18 utils-merge "1.0.1" vary "~1.1.2" -express@5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/express/-/express-5.1.0.tgz#d31beaf715a0016f0d53f47d3b4d7acf28c75cc9" - integrity sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA== - dependencies: - accepts "^2.0.0" - body-parser "^2.2.0" - content-disposition "^1.0.0" - content-type "^1.0.5" - cookie "^0.7.1" - cookie-signature "^1.2.1" - debug "^4.4.0" - encodeurl "^2.0.0" - escape-html "^1.0.3" - etag "^1.8.1" - finalhandler "^2.1.0" - fresh "^2.0.0" - http-errors "^2.0.0" - merge-descriptors "^2.0.0" - mime-types "^3.0.0" - on-finished "^2.4.1" - once "^1.4.0" - parseurl "^1.3.3" - proxy-addr "^2.0.7" - qs "^6.14.0" - range-parser "^1.2.1" - router "^2.2.0" - send "^1.1.0" - serve-static "^2.2.0" - statuses "^2.0.1" - type-is "^2.0.1" - vary "^1.1.2" - exsolve@^1.0.4, exsolve@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/exsolve/-/exsolve-1.0.7.tgz#3b74e4c7ca5c5f9a19c3626ca857309fa99f9e9e" @@ -22423,7 +22381,7 @@ mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== -mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.4, mkdirp@^0.5.5, mkdirp@^0.5.6: +mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.5, mkdirp@^0.5.6: version "0.5.6" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== @@ -22663,23 +22621,10 @@ ms@2.1.3, ms@^2.0.0, ms@^2.1.1, ms@^2.1.3: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -multer@1.4.4-lts.1: - version "1.4.4-lts.1" - resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.4-lts.1.tgz#24100f701a4611211cfae94ae16ea39bb314e04d" - integrity sha512-WeSGziVj6+Z2/MwQo3GvqzgR+9Uc+qt8SwHKh3gvNPiISKfsMfG4SvCOFYlxxgkXt7yIV2i1yczehm0EOKIxIg== - dependencies: - append-field "^1.0.0" - busboy "^1.0.0" - concat-stream "^1.5.2" - mkdirp "^0.5.4" - object-assign "^4.1.1" - type-is "^1.6.4" - xtend "^4.0.0" - -multer@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/multer/-/multer-2.0.1.tgz#3ed335ed2b96240e3df9e23780c91cfcf5d29202" - integrity sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ== +multer@2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/multer/-/multer-2.0.2.tgz#08a8aa8255865388c387aaf041426b0c87bf58dd" + integrity sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw== dependencies: append-field "^1.0.0" busboy "^1.6.0" @@ -26329,7 +26274,7 @@ readable-stream@2.3.7: string_decoder "~1.1.1" util-deprecate "~1.0.1" -readable-stream@^2.0.1, readable-stream@^2.0.5, readable-stream@^2.2.2, readable-stream@^2.3.5, readable-stream@~2.3.6: +readable-stream@^2.0.1, readable-stream@^2.0.5, readable-stream@^2.3.5, readable-stream@~2.3.6: version "2.3.8" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== @@ -29691,11 +29636,6 @@ tslib@2.4.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== -tslib@2.7.0: - version "2.7.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01" - integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA== - 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: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" @@ -29818,7 +29758,7 @@ type-fest@^4.18.2, type-fest@^4.39.1, type-fest@^4.6.0: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.41.0.tgz#6ae1c8e5731273c2bf1f58ad39cbae2c91a46c58" integrity sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA== -type-is@^1.6.18, type-is@^1.6.4, type-is@~1.6.18: +type-is@^1.6.18, type-is@~1.6.18: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== From 985873e3d3a6484953245952394a7f0bfedb02e0 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Wed, 1 Oct 2025 15:56:58 +0300 Subject: [PATCH 05/15] feat(node-core): Extend onnhandledrejection with ignore errors option (#17736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I’m introducing support for selectively suppressing specific errors. This is done by adding a new option that allows users to override the default logging modes. By passing this option down through the configuration, users will have finer control over which errors are logged versus ignored. Also added a default array for the errors, addressing an issue where Vercel’s flush is called during abort, causing unnecessary error logs. --- .../ignore-custom-name.js | 27 +++++++ .../ignore-default.js | 22 ++++++ .../onUnhandledRejectionIntegration/test.ts | 28 +++++++ .../src/integrations/onunhandledrejection.ts | 74 +++++++++++++++---- 4 files changed, 136 insertions(+), 15 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/ignore-custom-name.js create mode 100644 dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/ignore-default.js diff --git a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/ignore-custom-name.js b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/ignore-custom-name.js new file mode 100644 index 000000000000..7ff548624e5f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/ignore-custom-name.js @@ -0,0 +1,27 @@ +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.onUnhandledRejectionIntegration({ + // Use default mode: 'warn' - integration is active but should ignore CustomIgnoredError + ignore: [{ name: 'CustomIgnoredError' }], + }), + ], +}); + +// Create a custom error that should be ignored +class CustomIgnoredError extends Error { + constructor(message) { + super(message); + this.name = 'CustomIgnoredError'; + } +} + +setTimeout(() => { + process.stdout.write("I'm alive!"); + process.exit(0); +}, 500); + +// This should be ignored by the custom ignore matcher and not produce a warning +Promise.reject(new CustomIgnoredError('This error should be ignored')); diff --git a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/ignore-default.js b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/ignore-default.js new file mode 100644 index 000000000000..623aa8eaa8f7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/ignore-default.js @@ -0,0 +1,22 @@ +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + // Use default mode: 'warn' - integration is active but should ignore AI_NoOutputGeneratedError +}); + +// Create an error with the name that should be ignored by default +class AI_NoOutputGeneratedError extends Error { + constructor(message) { + super(message); + this.name = 'AI_NoOutputGeneratedError'; + } +} + +setTimeout(() => { + process.stdout.write("I'm alive!"); + process.exit(0); +}, 500); + +// This should be ignored by default and not produce a warning +Promise.reject(new AI_NoOutputGeneratedError('Stream aborted')); diff --git a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts index d3c8b4d599ff..cd0627664ea3 100644 --- a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts @@ -178,4 +178,32 @@ test rejection`); expect(transactionEvent!.contexts!.trace!.trace_id).toBe(errorEvent!.contexts!.trace!.trace_id); expect(transactionEvent!.contexts!.trace!.span_id).toBe(errorEvent!.contexts!.trace!.span_id); }); + + test('should not warn when AI_NoOutputGeneratedError is rejected (default ignore)', () => + new Promise(done => { + expect.assertions(3); + + const testScriptPath = path.resolve(__dirname, 'ignore-default.js'); + + childProcess.execFile('node', [testScriptPath], { encoding: 'utf8' }, (err, stdout, stderr) => { + expect(err).toBeNull(); + expect(stdout).toBe("I'm alive!"); + expect(stderr).toBe(''); // No warning should be shown + done(); + }); + })); + + test('should not warn when custom ignored error by name is rejected', () => + new Promise(done => { + expect.assertions(3); + + const testScriptPath = path.resolve(__dirname, 'ignore-custom-name.js'); + + childProcess.execFile('node', [testScriptPath], { encoding: 'utf8' }, (err, stdout, stderr) => { + expect(err).toBeNull(); + expect(stdout).toBe("I'm alive!"); + expect(stderr).toBe(''); // No warning should be shown + done(); + }); + })); }); diff --git a/packages/node-core/src/integrations/onunhandledrejection.ts b/packages/node-core/src/integrations/onunhandledrejection.ts index dbddb2a4c396..42a17e2e6c7e 100644 --- a/packages/node-core/src/integrations/onunhandledrejection.ts +++ b/packages/node-core/src/integrations/onunhandledrejection.ts @@ -1,24 +1,41 @@ import type { Client, IntegrationFn, SeverityLevel, Span } from '@sentry/core'; -import { captureException, consoleSandbox, defineIntegration, getClient, withActiveSpan } from '@sentry/core'; +import { + captureException, + consoleSandbox, + defineIntegration, + getClient, + isMatchingPattern, + withActiveSpan, +} from '@sentry/core'; import { logAndExitProcess } from '../utils/errorhandling'; type UnhandledRejectionMode = 'none' | 'warn' | 'strict'; +type IgnoreMatcher = { name?: string | RegExp; message?: string | RegExp }; + interface OnUnhandledRejectionOptions { /** * Option deciding what to do after capturing unhandledRejection, * that mimicks behavior of node's --unhandled-rejection flag. */ mode: UnhandledRejectionMode; + /** Rejection Errors to ignore (don't capture or warn). */ + ignore?: IgnoreMatcher[]; } const INTEGRATION_NAME = 'OnUnhandledRejection'; +const DEFAULT_IGNORES: IgnoreMatcher[] = [ + { + name: 'AI_NoOutputGeneratedError', // When stream aborts in Vercel AI SDK, Vercel flush() fails with an error + }, +]; + const _onUnhandledRejectionIntegration = ((options: Partial = {}) => { - const opts = { - mode: 'warn', - ...options, - } satisfies OnUnhandledRejectionOptions; + const opts: OnUnhandledRejectionOptions = { + mode: options.mode ?? 'warn', + ignore: [...DEFAULT_IGNORES, ...(options.ignore ?? [])], + }; return { name: INTEGRATION_NAME, @@ -28,27 +45,54 @@ const _onUnhandledRejectionIntegration = ((options: Partial; + const name = typeof errorLike.name === 'string' ? errorLike.name : ''; + const message = typeof errorLike.message === 'string' ? errorLike.message : String(reason); + + return { name, message }; +} + +/** Check if a matcher matches the reason */ +function isMatchingReason(matcher: IgnoreMatcher, errorInfo: ReturnType): boolean { + // name/message matcher + const nameMatches = matcher.name === undefined || isMatchingPattern(errorInfo.name, matcher.name, true); + + const messageMatches = matcher.message === undefined || isMatchingPattern(errorInfo.message, matcher.message); + + return nameMatches && messageMatches; +} + +/** Match helper */ +function matchesIgnore(list: IgnoreMatcher[], reason: unknown): boolean { + const errorInfo = extractErrorInfo(reason); + return list.some(matcher => isMatchingReason(matcher, errorInfo)); +} + +/** Core handler */ export function makeUnhandledPromiseHandler( client: Client, options: OnUnhandledRejectionOptions, ): (reason: unknown, promise: unknown) => void { return function sendUnhandledPromise(reason: unknown, promise: unknown): void { + // Only handle for the active client if (getClient() !== client) { return; } + // Skip if configured to ignore + if (matchesIgnore(options.ignore ?? [], reason)) { + return; + } + const level: SeverityLevel = options.mode === 'strict' ? 'fatal' : 'error'; // this can be set in places where we cannot reliably get access to the active span/error From b830cb20176e6496953db4a2a956e02798fe52e3 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Wed, 1 Oct 2025 16:36:30 +0300 Subject: [PATCH 06/15] feat(core): Support stream responses and tool calls for Google GenAI (#17664) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✅ This PR depends on #17625 and it should be reviewed only after that one is merged. This PR adds: 1. Streaming support for Google GenAI sdk instrumenting the following methods: 1. Models API Streaming - `models.generateContentStream()` - Stream content generation with real-time chunks 3. Chat API Streaming (chats.create) - `chat.sendMessageStream()` - Stream chat responses with conversation context 2. New tool calls attributes - GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE - Captures available tools from requests - GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE - Captures function calls from responses --------- Co-authored-by: Andrei <168741329+andreiborza@users.noreply.github.com> --- .../google-genai/scenario-streaming.mjs | 237 ++++++++++++++ .../tracing/google-genai/scenario-tools.mjs | 307 ++++++++++++++++++ .../suites/tracing/google-genai/scenario.mjs | 1 + .../suites/tracing/google-genai/test.ts | 284 ++++++++++++++++ .../core/src/utils/google-genai/constants.ts | 10 +- packages/core/src/utils/google-genai/index.ts | 133 +++++--- .../core/src/utils/google-genai/streaming.ts | 163 ++++++++++ packages/core/src/utils/google-genai/utils.ts | 11 + 8 files changed, 1105 insertions(+), 41 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-streaming.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-tools.mjs create mode 100644 packages/core/src/utils/google-genai/streaming.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-streaming.mjs new file mode 100644 index 000000000000..be5c75638694 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-streaming.mjs @@ -0,0 +1,237 @@ +import { GoogleGenAI } from '@google/genai'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockGoogleGenAIServer() { + const app = express(); + app.use(express.json()); + + // Streaming endpoint for models.generateContentStream and chat.sendMessageStream + app.post('/v1beta/models/:model\\:streamGenerateContent', (req, res) => { + const model = req.params.model; + + if (model === 'error-model') { + res.status(404).set('x-request-id', 'mock-request-123').end('Model not found'); + return; + } + + // Set headers for streaming response + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Transfer-Encoding', 'chunked'); + + // Create a mock stream + const mockStream = createMockStream(model); + + // Send chunks + const sendChunk = async () => { + const { value, done } = await mockStream.next(); + if (done) { + res.end(); + return; + } + + res.write(`data: ${JSON.stringify(value)}\n\n`); + setTimeout(sendChunk, 10); // Small delay between chunks + }; + + sendChunk(); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +// Helper function to create mock stream +async function* createMockStream(model) { + if (model === 'blocked-model') { + // First chunk: Contains promptFeedback with blockReason + yield { + promptFeedback: { + blockReason: 'SAFETY', + blockReasonMessage: 'The prompt was blocked due to safety concerns', + }, + responseId: 'mock-blocked-response-streaming-id', + modelVersion: 'gemini-1.5-pro', + }; + + // Note: In a real blocked scenario, there would typically be no more chunks + // But we'll add one more to test that processing stops after the error + yield { + candidates: [ + { + content: { + parts: [{ text: 'This should not be processed' }], + role: 'model', + }, + index: 0, + }, + ], + }; + return; + } + + // First chunk: Start of response with initial text + yield { + candidates: [ + { + content: { + parts: [{ text: 'Hello! ' }], + role: 'model', + }, + index: 0, + }, + ], + responseId: 'mock-response-streaming-id', + modelVersion: 'gemini-1.5-pro', + }; + + // Second chunk: More text content + yield { + candidates: [ + { + content: { + parts: [{ text: 'This is a streaming ' }], + role: 'model', + }, + index: 0, + }, + ], + }; + + // Third chunk: Final text content + yield { + candidates: [ + { + content: { + parts: [{ text: 'response from Google GenAI!' }], + role: 'model', + }, + index: 0, + }, + ], + }; + + // Final chunk: End with finish reason and usage metadata + yield { + candidates: [ + { + content: { + parts: [{ text: '' }], // Empty text in final chunk + role: 'model', + }, + finishReason: 'STOP', + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 12, + totalTokenCount: 22, + }, + }; +} + +async function run() { + const server = await startMockGoogleGenAIServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new GoogleGenAI({ + apiKey: 'mock-api-key', + httpOptions: { baseUrl: `http://localhost:${server.address().port}` }, + }); + + // Test 1: models.generateContentStream (streaming) + const streamResponse = await client.models.generateContentStream({ + model: 'gemini-1.5-flash', + config: { + temperature: 0.7, + topP: 0.9, + maxOutputTokens: 100, + }, + contents: [ + { + role: 'user', + parts: [{ text: 'Tell me about streaming' }], + }, + ], + }); + + // Consume the stream + for await (const _ of streamResponse) { + void _; + } + + // Test 2: chat.sendMessageStream (streaming) + const streamingChat = client.chats.create({ + model: 'gemini-1.5-pro', + config: { + temperature: 0.8, + topP: 0.9, + maxOutputTokens: 150, + }, + }); + + const chatStreamResponse = await streamingChat.sendMessageStream({ + message: 'Tell me a streaming joke', + }); + + // Consume the chat stream + for await (const _ of chatStreamResponse) { + void _; + } + + // Test 3: Blocked content streaming (should trigger error handling) + try { + const blockedStreamResponse = await client.models.generateContentStream({ + model: 'blocked-model', + config: { + temperature: 0.7, + }, + contents: [ + { + role: 'user', + parts: [{ text: 'This should be blocked' }], + }, + ], + }); + + // Consume the blocked stream + for await (const _ of blockedStreamResponse) { + void _; + } + } catch { + // Expected: The stream should be processed, but the span should be marked with error status + // The error handling happens in the streaming instrumentation, not as a thrown error + } + + // Test 4: Error handling for streaming + try { + const errorStreamResponse = await client.models.generateContentStream({ + model: 'error-model', + config: { + temperature: 0.7, + }, + contents: [ + { + role: 'user', + parts: [{ text: 'This will fail' }], + }, + ], + }); + + // Consume the error stream + for await (const _ of errorStreamResponse) { + void _; + } + } catch { + // Expected error + } + }); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-tools.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-tools.mjs new file mode 100644 index 000000000000..97984f2eb1ed --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-tools.mjs @@ -0,0 +1,307 @@ +import { GoogleGenAI } from '@google/genai'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockGoogleGenAIServer() { + const app = express(); + app.use(express.json()); + + // Non-streaming endpoint for models.generateContent + app.post('/v1beta/models/:model\\:generateContent', (req, res) => { + const { tools } = req.body; + + // Check if tools are provided to return function call response + if (tools && tools.length > 0) { + const response = { + candidates: [ + { + content: { + parts: [ + { + text: 'I need to check the light status first.', + }, + { + functionCall: { + id: 'call_light_control_1', + name: 'controlLight', + args: { + brightness: 0.3, + colorTemperature: 'warm', + }, + }, + }, + ], + role: 'model', + }, + finishReason: 'stop', + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 15, + candidatesTokenCount: 8, + totalTokenCount: 23, + }, + }; + + // Add functionCalls getter, this should exist in the response object + Object.defineProperty(response, 'functionCalls', { + get: function () { + return [ + { + id: 'call_light_control_1', + name: 'controlLight', + args: { + brightness: 0.3, + colorTemperature: 'warm', + }, + }, + ]; + }, + }); + + res.send(response); + return; + } + + // Regular response without tools + res.send({ + candidates: [ + { + content: { + parts: [ + { + text: 'Mock response from Google GenAI without tools!', + }, + ], + role: 'model', + }, + finishReason: 'stop', + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 8, + candidatesTokenCount: 12, + totalTokenCount: 20, + }, + }); + }); + + // Streaming endpoint for models.generateContentStream + // And chat.sendMessageStream + app.post('/v1beta/models/:model\\:streamGenerateContent', (req, res) => { + const { tools } = req.body; + + // Set headers for streaming response + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Transfer-Encoding', 'chunked'); + + // Create a mock stream + const mockStream = createMockToolsStream({ tools }); + + // Send chunks + const sendChunk = async () => { + // Testing .next() works as expected + const { value, done } = await mockStream.next(); + if (done) { + res.end(); + return; + } + + res.write(`data: ${JSON.stringify(value)}\n\n`); + setTimeout(sendChunk, 10); // Small delay between chunks + }; + + sendChunk(); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +// Helper function to create mock stream +async function* createMockToolsStream({ tools }) { + // Check if tools are provided to return function call response + if (tools && tools.length > 0) { + // First chunk: Text response + yield { + candidates: [ + { + content: { + parts: [{ text: 'Let me control the lights for you.' }], + role: 'model', + }, + index: 0, + }, + ], + responseId: 'mock-response-tools-id', + modelVersion: 'gemini-2.0-flash-001', + }; + + // Second chunk: Function call + yield { + candidates: [ + { + content: { + parts: [ + { + functionCall: { + id: 'call_light_stream_1', + name: 'controlLight', + args: { + brightness: 0.5, + colorTemperature: 'cool', + }, + }, + }, + ], + role: 'model', + }, + index: 0, + }, + ], + }; + + // Final chunk: End with finish reason and usage metadata + yield { + candidates: [ + { + content: { + parts: [{ text: ' Done!' }], // Additional text in final chunk + role: 'model', + }, + finishReason: 'STOP', + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 12, + candidatesTokenCount: 10, + totalTokenCount: 22, + }, + }; + return; + } + + // Regular stream without tools + yield { + candidates: [ + { + content: { + parts: [{ text: 'Mock streaming response' }], + role: 'model', + }, + index: 0, + }, + ], + responseId: 'mock-response-tools-id', + modelVersion: 'gemini-2.0-flash-001', + }; + + // Final chunk + yield { + candidates: [ + { + content: { + parts: [{ text: ' from Google GenAI!' }], + role: 'model', + }, + finishReason: 'STOP', + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 12, + totalTokenCount: 22, + }, + }; +} + +async function run() { + const server = await startMockGoogleGenAIServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new GoogleGenAI({ + apiKey: 'mock-api-key', + httpOptions: { baseUrl: `http://localhost:${server.address().port}` }, + }); + + // Test 1: Non-streaming with tools + await client.models.generateContent({ + model: 'gemini-2.0-flash-001', + contents: 'Dim the lights so the room feels cozy and warm.', + config: { + tools: [ + { + functionDeclarations: [ + { + name: 'controlLight', + parametersJsonSchema: { + type: 'object', + properties: { + brightness: { + type: 'number', + }, + colorTemperature: { + type: 'string', + }, + }, + required: ['brightness', 'colorTemperature'], + }, + }, + ], + }, + ], + }, + }); + + // Test 2: Streaming with tools + const stream = await client.models.generateContentStream({ + model: 'gemini-2.0-flash-001', + contents: 'Turn on the lights with medium brightness.', + config: { + tools: [ + { + functionDeclarations: [ + { + name: 'controlLight', + parametersJsonSchema: { + type: 'object', + properties: { + brightness: { + type: 'number', + }, + colorTemperature: { + type: 'string', + }, + }, + required: ['brightness', 'colorTemperature'], + }, + }, + ], + }, + ], + }, + }); + + // Consume the stream to trigger instrumentation + for await (const _ of stream) { + void _; + } + + // Test 3: Without tools for comparison + await client.models.generateContent({ + model: 'gemini-2.0-flash-001', + contents: 'Tell me about the weather.', + }); + }); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario.mjs index ddb9e16b8254..91c75886e410 100644 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario.mjs @@ -54,6 +54,7 @@ async function run() { }); // Test 1: chats.create and sendMessage flow + // This should generate two spans: one for chats.create and one for sendMessage const chat = client.chats.create({ model: 'gemini-1.5-pro', config: { diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts index 9aa5523c61d7..92d669c7e10f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts @@ -169,6 +169,7 @@ describe('Google GenAI integration', () => { 'gen_ai.request.messages': expect.any(String), // Should include messages when recordInputs: true 'gen_ai.response.text': expect.any(String), // Should include response text when recordOutputs: true }), + description: expect.not.stringContaining('stream-response'), // Non-streaming span }), ]), }; @@ -202,4 +203,287 @@ describe('Google GenAI integration', () => { .completed(); }); }); + + const EXPECTED_TRANSACTION_TOOLS = { + transaction: 'main', + spans: expect.arrayContaining([ + // Non-streaming with tools + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-2.0-flash-001', + 'gen_ai.request.available_tools': expect.any(String), // Should include tools + 'gen_ai.request.messages': expect.any(String), // Should include contents + 'gen_ai.response.text': expect.any(String), // Should include response text + 'gen_ai.response.tool_calls': expect.any(String), // Should include tool calls + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 8, + 'gen_ai.usage.total_tokens': 23, + }), + description: 'models gemini-2.0-flash-001', + op: 'gen_ai.models', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + // Streaming with tools + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-2.0-flash-001', + 'gen_ai.request.available_tools': expect.any(String), // Should include tools + 'gen_ai.request.messages': expect.any(String), // Should include contents + 'gen_ai.response.streaming': true, + 'gen_ai.response.text': expect.any(String), // Should include response text + 'gen_ai.response.tool_calls': expect.any(String), // Should include tool calls + 'gen_ai.response.id': 'mock-response-tools-id', + 'gen_ai.response.model': 'gemini-2.0-flash-001', + 'gen_ai.usage.input_tokens': 12, + 'gen_ai.usage.output_tokens': 10, + 'gen_ai.usage.total_tokens': 22, + }), + description: 'models gemini-2.0-flash-001 stream-response', + op: 'gen_ai.models', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + // Without tools for comparison + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-2.0-flash-001', + 'gen_ai.request.messages': expect.any(String), // Should include contents + 'gen_ai.response.text': expect.any(String), // Should include response text + 'gen_ai.usage.input_tokens': 8, + 'gen_ai.usage.output_tokens': 12, + 'gen_ai.usage.total_tokens': 20, + }), + description: 'models gemini-2.0-flash-001', + op: 'gen_ai.models', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario-tools.mjs', 'instrument-with-options.mjs', (createRunner, test) => { + test('creates google genai related spans with tool calls', async () => { + await createRunner().ignore('event').expect({ transaction: EXPECTED_TRANSACTION_TOOLS }).start().completed(); + }); + }); + + const EXPECTED_TRANSACTION_STREAMING = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - models.generateContentStream (streaming) + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-1.5-flash', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.top_p': 0.9, + 'gen_ai.request.max_tokens': 100, + 'gen_ai.response.streaming': true, + 'gen_ai.response.id': 'mock-response-streaming-id', + 'gen_ai.response.model': 'gemini-1.5-pro', + 'gen_ai.response.finish_reasons': '["STOP"]', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 12, + 'gen_ai.usage.total_tokens': 22, + }), + description: 'models gemini-1.5-flash stream-response', + op: 'gen_ai.models', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + // Second span - chat.create + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-1.5-pro', + 'gen_ai.request.temperature': 0.8, + 'gen_ai.request.top_p': 0.9, + 'gen_ai.request.max_tokens': 150, + }), + description: 'chat gemini-1.5-pro create', + op: 'gen_ai.chat', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + // Third span - chat.sendMessageStream (streaming) + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-1.5-pro', + 'gen_ai.response.streaming': true, + 'gen_ai.response.id': 'mock-response-streaming-id', + 'gen_ai.response.model': 'gemini-1.5-pro', + }), + description: 'chat gemini-1.5-pro stream-response', + op: 'gen_ai.chat', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + // Fourth span - blocked content streaming + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.google_genai', + }), + description: 'models blocked-model stream-response', + op: 'gen_ai.models', + origin: 'auto.ai.google_genai', + status: 'unknown_error', + }), + // Fifth span - error handling for streaming + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.google_genai', + }), + description: 'models error-model stream-response', + op: 'gen_ai.models', + origin: 'auto.ai.google_genai', + status: 'internal_error', + }), + ]), + }; + + const EXPECTED_TRANSACTION_STREAMING_PII_TRUE = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - models.generateContentStream (streaming) with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-1.5-flash', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.top_p': 0.9, + 'gen_ai.request.max_tokens': 100, + 'gen_ai.request.messages': expect.any(String), // Should include contents when recordInputs: true + 'gen_ai.response.streaming': true, + 'gen_ai.response.id': 'mock-response-streaming-id', + 'gen_ai.response.model': 'gemini-1.5-pro', + 'gen_ai.response.finish_reasons': '["STOP"]', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 12, + 'gen_ai.usage.total_tokens': 22, + }), + description: 'models gemini-1.5-flash stream-response', + op: 'gen_ai.models', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + // Second span - chat.create + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-1.5-pro', + 'gen_ai.request.temperature': 0.8, + 'gen_ai.request.top_p': 0.9, + 'gen_ai.request.max_tokens': 150, + }), + description: 'chat gemini-1.5-pro create', + op: 'gen_ai.chat', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + // Third span - chat.sendMessageStream (streaming) with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-1.5-pro', + 'gen_ai.request.messages': expect.any(String), // Should include message when recordInputs: true + 'gen_ai.response.streaming': true, + 'gen_ai.response.id': 'mock-response-streaming-id', + 'gen_ai.response.model': 'gemini-1.5-pro', + 'gen_ai.response.finish_reasons': '["STOP"]', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 12, + 'gen_ai.usage.total_tokens': 22, + }), + description: 'chat gemini-1.5-pro stream-response', + op: 'gen_ai.chat', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + // Fourth span - blocked content stream with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'blocked-model', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.messages': expect.any(String), // Should include contents when recordInputs: true + 'gen_ai.response.streaming': true, + }), + description: 'models blocked-model stream-response', + op: 'gen_ai.models', + origin: 'auto.ai.google_genai', + status: 'unknown_error', + }), + // Fifth span - error handling for streaming with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'error-model', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.messages': expect.any(String), // Should include contents when recordInputs: true + }), + description: 'models error-model stream-response', + op: 'gen_ai.models', + origin: 'auto.ai.google_genai', + status: 'internal_error', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario-streaming.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates google genai streaming spans with sendDefaultPii: false', async () => { + await createRunner().ignore('event').expect({ transaction: EXPECTED_TRANSACTION_STREAMING }).start().completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario-streaming.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('creates google genai streaming spans with sendDefaultPii: true', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_STREAMING_PII_TRUE }) + .start() + .completed(); + }); + }); }); diff --git a/packages/core/src/utils/google-genai/constants.ts b/packages/core/src/utils/google-genai/constants.ts index 8617460482c6..b06e46e18755 100644 --- a/packages/core/src/utils/google-genai/constants.ts +++ b/packages/core/src/utils/google-genai/constants.ts @@ -2,7 +2,15 @@ export const GOOGLE_GENAI_INTEGRATION_NAME = 'Google_GenAI'; // https://ai.google.dev/api/rest/v1/models/generateContent // https://ai.google.dev/api/rest/v1/chats/sendMessage -export const GOOGLE_GENAI_INSTRUMENTED_METHODS = ['models.generateContent', 'chats.create', 'sendMessage'] as const; +// https://googleapis.github.io/js-genai/release_docs/classes/models.Models.html#generatecontentstream +// https://googleapis.github.io/js-genai/release_docs/classes/chats.Chat.html#sendmessagestream +export const GOOGLE_GENAI_INSTRUMENTED_METHODS = [ + 'models.generateContent', + 'models.generateContentStream', + 'chats.create', + 'sendMessage', + 'sendMessageStream', +] as const; // Constants for internal use export const GOOGLE_GENAI_SYSTEM_NAME = 'google_genai'; diff --git a/packages/core/src/utils/google-genai/index.ts b/packages/core/src/utils/google-genai/index.ts index 58d7e2e6b5e6..20e6e2a53606 100644 --- a/packages/core/src/utils/google-genai/index.ts +++ b/packages/core/src/utils/google-genai/index.ts @@ -1,10 +1,12 @@ import { getClient } from '../../currentScopes'; import { captureException } from '../../exports'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; -import { startSpan } from '../../tracing/trace'; +import { SPAN_STATUS_ERROR } from '../../tracing'; +import { startSpan, startSpanManual } from '../../tracing/trace'; import type { Span, SpanAttributeValue } from '../../types-hoist/span'; import { GEN_AI_OPERATION_NAME_ATTRIBUTE, + GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, @@ -14,6 +16,7 @@ import { GEN_AI_REQUEST_TOP_K_ATTRIBUTE, GEN_AI_REQUEST_TOP_P_ATTRIBUTE, GEN_AI_RESPONSE_TEXT_ATTRIBUTE, + GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, @@ -22,6 +25,7 @@ import { import { buildMethodPath, getFinalOperationName, getSpanOperation } from '../ai/utils'; import { handleCallbackErrors } from '../handleCallbackErrors'; import { CHAT_PATH, CHATS_CREATE_METHOD, GOOGLE_GENAI_SYSTEM_NAME } from './constants'; +import { instrumentStream } from './streaming'; import type { Candidate, ContentPart, @@ -29,7 +33,7 @@ import type { GoogleGenAIOptions, GoogleGenAIResponse, } from './types'; -import { shouldInstrument } from './utils'; +import { isStreamingMethod, shouldInstrument } from './utils'; /** * Extract model from parameters or chat context object @@ -91,8 +95,8 @@ function extractConfigAttributes(config: Record): Record, context?: unknown, ): Record { const attributes: Record = { @@ -101,14 +105,21 @@ function extractRequestAttributes( [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.google_genai', }; - if (args.length > 0 && typeof args[0] === 'object' && args[0] !== null) { - const params = args[0] as Record; - + if (params) { attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] = extractModel(params, context); // Extract generation config parameters if ('config' in params && typeof params.config === 'object' && params.config) { - Object.assign(attributes, extractConfigAttributes(params.config as Record)); + const config = params.config as Record; + Object.assign(attributes, extractConfigAttributes(config)); + + // Extract available tools from config + if ('tools' in config && Array.isArray(config.tools)) { + const functionDeclarations = config.tools.map( + (tool: { functionDeclarations: unknown[] }) => tool.functionDeclarations, + ); + attributes[GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE] = JSON.stringify(functionDeclarations); + } } } else { attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] = extractModel({}, context); @@ -186,6 +197,16 @@ function addResponseAttributes(span: Span, response: GoogleGenAIResponse, record }); } } + + // Add tool calls if recordOutputs is enabled + if (recordOutputs && response.functionCalls) { + const functionCalls = response.functionCalls; + if (Array.isArray(functionCalls) && functionCalls.length > 0) { + span.setAttributes({ + [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(functionCalls), + }); + } + } } /** @@ -201,43 +222,75 @@ function instrumentMethod( ): (...args: T) => R | Promise { const isSyncCreate = methodPath === CHATS_CREATE_METHOD; - const run = (...args: T): R | Promise => { - const requestAttributes = extractRequestAttributes(args, methodPath, context); - const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; - const operationName = getFinalOperationName(methodPath); - - // Single span for both sync and async operations - return startSpan( - { - name: isSyncCreate ? `${operationName} ${model} create` : `${operationName} ${model}`, - op: getSpanOperation(methodPath), - attributes: requestAttributes, - }, - (span: Span) => { - if (options.recordInputs && args[0] && typeof args[0] === 'object') { - addPrivateRequestAttributes(span, args[0] as Record); - } - - return handleCallbackErrors( - () => originalMethod.apply(context, args), - error => { - captureException(error, { - mechanism: { handled: false, type: 'auto.ai.google_genai', data: { function: methodPath } }, - }); + return new Proxy(originalMethod, { + apply(target, _, args: T): R | Promise { + const params = args[0] as Record | undefined; + const requestAttributes = extractRequestAttributes(methodPath, params, context); + const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; + const operationName = getFinalOperationName(methodPath); + + // Check if this is a streaming method + if (isStreamingMethod(methodPath)) { + // Use startSpanManual for streaming methods to control span lifecycle + return startSpanManual( + { + name: `${operationName} ${model} stream-response`, + op: getSpanOperation(methodPath), + attributes: requestAttributes, }, - () => {}, - result => { - // Only add response attributes for content-producing methods, not for chats.create - if (!isSyncCreate) { - addResponseAttributes(span, result, options.recordOutputs); + async (span: Span) => { + try { + if (options.recordInputs && params) { + addPrivateRequestAttributes(span, params); + } + const stream = await target.apply(context, args); + return instrumentStream(stream, span, Boolean(options.recordOutputs)) as R; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: 'auto.ai.google_genai', + data: { function: methodPath }, + }, + }); + span.end(); + throw error; } }, ); - }, - ); - }; - - return run; + } + // Single span for both sync and async operations + return startSpan( + { + name: isSyncCreate ? `${operationName} ${model} create` : `${operationName} ${model}`, + op: getSpanOperation(methodPath), + attributes: requestAttributes, + }, + (span: Span) => { + if (options.recordInputs && params) { + addPrivateRequestAttributes(span, params); + } + + return handleCallbackErrors( + () => target.apply(context, args), + error => { + captureException(error, { + mechanism: { handled: false, type: 'auto.ai.google_genai', data: { function: methodPath } }, + }); + }, + () => {}, + result => { + // Only add response attributes for content-producing methods, not for chats.create + if (!isSyncCreate) { + addResponseAttributes(span, result, options.recordOutputs); + } + }, + ); + }, + ); + }, + }) as (...args: T) => R | Promise; } /** diff --git a/packages/core/src/utils/google-genai/streaming.ts b/packages/core/src/utils/google-genai/streaming.ts new file mode 100644 index 000000000000..b9462e8c90dd --- /dev/null +++ b/packages/core/src/utils/google-genai/streaming.ts @@ -0,0 +1,163 @@ +import { captureException } from '../../exports'; +import { SPAN_STATUS_ERROR } from '../../tracing'; +import type { Span, SpanAttributeValue } from '../../types-hoist/span'; +import { + GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, + GEN_AI_RESPONSE_ID_ATTRIBUTE, + GEN_AI_RESPONSE_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_STREAMING_ATTRIBUTE, + GEN_AI_RESPONSE_TEXT_ATTRIBUTE, + GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, + GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, +} from '../ai/gen-ai-attributes'; +import type { GoogleGenAIResponse } from './types'; + +/** + * State object used to accumulate information from a stream of Google GenAI events. + */ +interface StreamingState { + /** Collected response text fragments (for output recording). */ + responseTexts: string[]; + /** Reasons for finishing the response, as reported by the API. */ + finishReasons: string[]; + /** The response ID. */ + responseId?: string; + /** The model name. */ + responseModel?: string; + /** Number of prompt/input tokens used. */ + promptTokens?: number; + /** Number of completion/output tokens used. */ + completionTokens?: number; + /** Number of total tokens used. */ + totalTokens?: number; + /** Accumulated tool calls (finalized) */ + toolCalls: Array>; +} + +/** + * Checks if a response chunk contains an error + * @param chunk - The response chunk to check + * @param span - The span to update if error is found + * @returns Whether an error occurred + */ +function isErrorChunk(chunk: GoogleGenAIResponse, span: Span): boolean { + const feedback = chunk?.promptFeedback; + if (feedback?.blockReason) { + const message = feedback.blockReasonMessage ?? feedback.blockReason; + span.setStatus({ code: SPAN_STATUS_ERROR, message: `Content blocked: ${message}` }); + captureException(`Content blocked: ${message}`, { + mechanism: { handled: false, type: 'auto.ai.google_genai' }, + }); + return true; + } + return false; +} + +/** + * Processes response metadata from a chunk + * @param chunk - The response chunk to process + * @param state - The state of the streaming process + */ +function handleResponseMetadata(chunk: GoogleGenAIResponse, state: StreamingState): void { + if (typeof chunk.responseId === 'string') state.responseId = chunk.responseId; + if (typeof chunk.modelVersion === 'string') state.responseModel = chunk.modelVersion; + + const usage = chunk.usageMetadata; + if (usage) { + if (typeof usage.promptTokenCount === 'number') state.promptTokens = usage.promptTokenCount; + if (typeof usage.candidatesTokenCount === 'number') state.completionTokens = usage.candidatesTokenCount; + if (typeof usage.totalTokenCount === 'number') state.totalTokens = usage.totalTokenCount; + } +} + +/** + * Processes candidate content from a response chunk + * @param chunk - The response chunk to process + * @param state - The state of the streaming process + * @param recordOutputs - Whether to record outputs + */ +function handleCandidateContent(chunk: GoogleGenAIResponse, state: StreamingState, recordOutputs: boolean): void { + if (Array.isArray(chunk.functionCalls)) { + state.toolCalls.push(...chunk.functionCalls); + } + + for (const candidate of chunk.candidates ?? []) { + if (candidate?.finishReason && !state.finishReasons.includes(candidate.finishReason)) { + state.finishReasons.push(candidate.finishReason); + } + + for (const part of candidate?.content?.parts ?? []) { + if (recordOutputs && part.text) state.responseTexts.push(part.text); + if (part.functionCall) { + state.toolCalls.push({ + type: 'function', + id: part.functionCall.id, + name: part.functionCall.name, + arguments: part.functionCall.args, + }); + } + } + } +} + +/** + * Processes a single chunk from the Google GenAI stream + * @param chunk - The chunk to process + * @param state - The state of the streaming process + * @param recordOutputs - Whether to record outputs + * @param span - The span to update + */ +function processChunk(chunk: GoogleGenAIResponse, state: StreamingState, recordOutputs: boolean, span: Span): void { + if (!chunk || isErrorChunk(chunk, span)) return; + handleResponseMetadata(chunk, state); + handleCandidateContent(chunk, state, recordOutputs); +} + +/** + * Instruments an async iterable stream of Google GenAI response chunks, updates the span with + * streaming attributes and (optionally) the aggregated output text, and yields + * each chunk from the input stream unchanged. + */ +export async function* instrumentStream( + stream: AsyncIterable, + span: Span, + recordOutputs: boolean, +): AsyncGenerator { + const state: StreamingState = { + responseTexts: [], + finishReasons: [], + toolCalls: [], + }; + + try { + for await (const chunk of stream) { + processChunk(chunk, state, recordOutputs, span); + yield chunk; + } + } finally { + const attrs: Record = { + [GEN_AI_RESPONSE_STREAMING_ATTRIBUTE]: true, + }; + + if (state.responseId) attrs[GEN_AI_RESPONSE_ID_ATTRIBUTE] = state.responseId; + if (state.responseModel) attrs[GEN_AI_RESPONSE_MODEL_ATTRIBUTE] = state.responseModel; + if (state.promptTokens !== undefined) attrs[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE] = state.promptTokens; + if (state.completionTokens !== undefined) attrs[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE] = state.completionTokens; + if (state.totalTokens !== undefined) attrs[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE] = state.totalTokens; + + if (state.finishReasons.length) { + attrs[GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE] = JSON.stringify(state.finishReasons); + } + if (recordOutputs && state.responseTexts.length) { + attrs[GEN_AI_RESPONSE_TEXT_ATTRIBUTE] = state.responseTexts.join(''); + } + if (recordOutputs && state.toolCalls.length) { + attrs[GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE] = JSON.stringify(state.toolCalls); + } + + span.setAttributes(attrs); + span.end(); + } +} diff --git a/packages/core/src/utils/google-genai/utils.ts b/packages/core/src/utils/google-genai/utils.ts index c7a18477c7dd..a394ed64a1bb 100644 --- a/packages/core/src/utils/google-genai/utils.ts +++ b/packages/core/src/utils/google-genai/utils.ts @@ -14,3 +14,14 @@ export function shouldInstrument(methodPath: string): methodPath is GoogleGenAII const methodName = methodPath.split('.').pop(); return GOOGLE_GENAI_INSTRUMENTED_METHODS.includes(methodName as GoogleGenAIIstrumentedMethod); } + +/** + * Check if a method is a streaming method + */ +export function isStreamingMethod(methodPath: string): boolean { + return ( + methodPath.includes('Stream') || + methodPath.endsWith('generateContentStream') || + methodPath.endsWith('sendMessageStream') + ); +} From 743c4ebf1690fa4d85dc2dbd6e44eb455219aacb Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 2 Oct 2025 10:17:46 +0200 Subject: [PATCH 07/15] feat(nextjs): Attach headers using client hook (#17831) --- .../app/route-handler/[xoxo]/edge/route.ts | 8 ++++ .../app/route-handler/[xoxo]/node/route.ts | 7 ++++ .../nextjs-15/tests/pageload-tracing.test.ts | 5 --- .../nextjs-15/tests/route-handler.test.ts | 40 +++++++++++++++++++ .../wrapApiHandlerWithSentry.ts | 2 - .../wrapGenerationFunctionWithSentry.ts | 6 --- .../src/common/wrapMiddlewareWithSentry.ts | 6 --- .../src/common/wrapRouteHandlerWithSentry.ts | 6 --- .../common/wrapServerComponentWithSentry.ts | 6 --- packages/nextjs/src/edge/index.ts | 10 +++++ .../src/edge/wrapApiHandlerWithSentry.ts | 1 - packages/nextjs/src/server/index.ts | 12 ++++-- 12 files changed, 74 insertions(+), 35 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/route-handler/[xoxo]/edge/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/route-handler/[xoxo]/node/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/tests/route-handler.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/route-handler/[xoxo]/edge/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/app/route-handler/[xoxo]/edge/route.ts new file mode 100644 index 000000000000..7cd1fc7e332c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/route-handler/[xoxo]/edge/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from 'next/server'; + +export const runtime = 'edge'; +export const dynamic = 'force-dynamic'; + +export async function GET() { + return NextResponse.json({ message: 'Hello Edge Route Handler' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/route-handler/[xoxo]/node/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/app/route-handler/[xoxo]/node/route.ts new file mode 100644 index 000000000000..5bc418f077aa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/route-handler/[xoxo]/node/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + return NextResponse.json({ message: 'Hello Node Route Handler' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/pageload-tracing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/pageload-tracing.test.ts index 3cad4a546508..c9a9fc44bdba 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/pageload-tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/pageload-tracing.test.ts @@ -24,11 +24,6 @@ test('App router transactions should be attached to the pageload request span', }); test('extracts HTTP request headers as span attributes', async ({ baseURL }) => { - test.skip( - process.env.TEST_ENV === 'prod-turbopack' || process.env.TEST_ENV === 'dev-turbopack', - 'Incoming fetch request headers are not added as span attributes when Turbopack is enabled (addHeadersAsAttributes)', - ); - const serverTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { return transactionEvent?.transaction === 'GET /pageload-tracing'; }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/route-handler.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/route-handler.test.ts new file mode 100644 index 000000000000..f9dedccb4923 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/route-handler.test.ts @@ -0,0 +1,40 @@ +import test, { expect } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Should create a transaction for node route handlers', async ({ request }) => { + const routehandlerTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + console.log(transactionEvent?.transaction); + return transactionEvent?.transaction === 'GET /route-handler/[xoxo]/node'; + }); + + const response = await request.get('/route-handler/123/node', { headers: { 'x-charly': 'gomez' } }); + expect(await response.json()).toStrictEqual({ message: 'Hello Node Route Handler' }); + + const routehandlerTransaction = await routehandlerTransactionPromise; + + expect(routehandlerTransaction.contexts?.trace?.status).toBe('ok'); + expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); + + // This is flaking on dev mode + if (process.env.TEST_ENV !== 'development' && process.env.TEST_ENV !== 'dev-turbopack') { + expect(routehandlerTransaction.contexts?.trace?.data?.['http.request.header.x_charly']).toBe('gomez'); + } +}); + +test('Should create a transaction for edge route handlers', async ({ request }) => { + // This test only works for webpack builds on non-async param extraction + // todo: check if we can set request headers for edge on sdkProcessingMetadata + test.skip(); + const routehandlerTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return transactionEvent?.transaction === 'GET /route-handler/[xoxo]/edge'; + }); + + const response = await request.get('/route-handler/123/edge', { headers: { 'x-charly': 'gomez' } }); + expect(await response.json()).toStrictEqual({ message: 'Hello Edge Route Handler' }); + + const routehandlerTransaction = await routehandlerTransactionPromise; + + expect(routehandlerTransaction.contexts?.trace?.status).toBe('ok'); + expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); + expect(routehandlerTransaction.contexts?.trace?.data?.['http.request.header.x_charly']).toBe('gomez'); +}); diff --git a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts index 4cf8fde751fb..8f02df798f84 100644 --- a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts @@ -15,7 +15,6 @@ import { } from '@sentry/core'; import type { NextApiRequest } from 'next'; import type { AugmentedNextApiResponse, NextApiHandler } from '../types'; -import { addHeadersAsAttributes } from '../utils/addHeadersAsAttributes'; import { flushSafelyWithTimeout } from '../utils/responseEnd'; import { dropNextjsRootContext, escapeNextjsTracing } from '../utils/tracingUtils'; @@ -88,7 +87,6 @@ export function wrapApiHandlerWithSentry(apiHandler: NextApiHandler, parameteriz attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.nextjs', - ...addHeadersAsAttributes(normalizedRequest.headers || {}), }, }, async span => { diff --git a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts index 323d4d1f2e3b..c22910df43bf 100644 --- a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts +++ b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts @@ -22,7 +22,6 @@ import { import type { GenerationFunctionContext } from '../common/types'; import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; import { TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL } from './span-attributes-with-logic-attached'; -import { addHeadersAsAttributes } from './utils/addHeadersAsAttributes'; import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils'; import { getSanitizedRequestUrl } from './utils/urls'; import { maybeExtractSynchronousParamsAndSearchParams } from './utils/wrapperUtils'; @@ -64,11 +63,6 @@ export function wrapGenerationFunctionWithSentry a const headersDict = headers ? winterCGHeadersToDict(headers) : undefined; - if (activeSpan) { - const rootSpan = getRootSpan(activeSpan); - addHeadersAsAttributes(headers, rootSpan); - } - let data: Record | undefined = undefined; if (getClient()?.getOptions().sendDefaultPii) { const props: unknown = args[0]; diff --git a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts index ab05fbd5e944..07694d659e57 100644 --- a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts +++ b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts @@ -13,7 +13,6 @@ import { winterCGRequestToRequestData, withIsolationScope, } from '@sentry/core'; -import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; import type { EdgeRouteHandler } from '../edge/types'; @@ -60,7 +59,6 @@ export function wrapMiddlewareWithSentry( let spanName: string; let spanSource: TransactionSource; - let headerAttributes: Record = {}; if (req instanceof Request) { isolationScope.setSDKProcessingMetadata({ @@ -68,8 +66,6 @@ export function wrapMiddlewareWithSentry( }); spanName = `middleware ${req.method} ${new URL(req.url).pathname}`; spanSource = 'url'; - - headerAttributes = addHeadersAsAttributes(req.headers); } else { spanName = 'middleware'; spanSource = 'component'; @@ -88,7 +84,6 @@ export function wrapMiddlewareWithSentry( const rootSpan = getRootSpan(activeSpan); if (rootSpan) { setCapturedScopesOnSpan(rootSpan, currentScope, isolationScope); - rootSpan.setAttributes(headerAttributes); } } @@ -99,7 +94,6 @@ export function wrapMiddlewareWithSentry( attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: spanSource, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs.wrap_middleware', - ...headerAttributes, }, }, () => { diff --git a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts index 54858a9bdae2..068ab7960ae4 100644 --- a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts +++ b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts @@ -19,7 +19,6 @@ import { } from '@sentry/core'; import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; import type { RouteHandlerContext } from './types'; -import { addHeadersAsAttributes } from './utils/addHeadersAsAttributes'; import { flushSafelyWithTimeout } from './utils/responseEnd'; import { commonObjectToIsolationScope } from './utils/tracingUtils'; @@ -40,10 +39,6 @@ export function wrapRouteHandlerWithSentry any>( const activeSpan = getActiveSpan(); const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; - if (rootSpan && process.env.NEXT_RUNTIME !== 'edge') { - addHeadersAsAttributes(headers, rootSpan); - } - let edgeRuntimeIsolationScopeOverride: Scope | undefined; if (rootSpan && process.env.NEXT_RUNTIME === 'edge') { const isolationScope = commonObjectToIsolationScope(headers); @@ -55,7 +50,6 @@ export function wrapRouteHandlerWithSentry any>( rootSpan.updateName(`${method} ${parameterizedRoute}`); rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'http.server'); - addHeadersAsAttributes(headers, rootSpan); } return withIsolationScope( diff --git a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts index d25225a149f9..1f522dbf212f 100644 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts @@ -24,7 +24,6 @@ import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/ import type { ServerComponentContext } from '../common/types'; import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; import { TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL } from './span-attributes-with-logic-attached'; -import { addHeadersAsAttributes } from './utils/addHeadersAsAttributes'; import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils'; import { getSanitizedRequestUrl } from './utils/urls'; import { maybeExtractSynchronousParamsAndSearchParams } from './utils/wrapperUtils'; @@ -62,11 +61,6 @@ export function wrapServerComponentWithSentry any> const headersDict = context.headers ? winterCGHeadersToDict(context.headers) : undefined; - if (activeSpan) { - const rootSpan = getRootSpan(activeSpan); - addHeadersAsAttributes(context.headers, rootSpan); - } - let params: Record | undefined = undefined; if (getClient()?.getOptions().sendDefaultPii) { diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 7982667f0c3f..6469e3c6a2c8 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -1,6 +1,7 @@ import { applySdkMetadata, getGlobalScope, + getIsolationScope, getRootSpan, GLOBAL_OBJ, registerSpanErrorInstrumentation, @@ -13,6 +14,7 @@ import { } from '@sentry/core'; import type { VercelEdgeOptions } from '@sentry/vercel-edge'; import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; +import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; import { isBuild } from '../common/utils/isBuild'; import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; @@ -59,6 +61,8 @@ export function init(options: VercelEdgeOptions = {}): void { client?.on('spanStart', span => { const spanAttributes = spanToJSON(span).data; + const rootSpan = getRootSpan(span); + const isRootSpan = span === rootSpan; // Mark all spans generated by Next.js as 'auto' if (spanAttributes?.['next.span_type'] !== undefined) { @@ -70,6 +74,12 @@ export function init(options: VercelEdgeOptions = {}): void { span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'http.server.middleware'); span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'url'); } + + if (isRootSpan) { + // todo: check if we can set request headers for edge on sdkProcessingMetadata + const headers = getIsolationScope().getScopeData().sdkProcessingMetadata?.normalizedRequest?.headers; + addHeadersAsAttributes(headers, rootSpan); + } }); // Use the preprocessEvent hook instead of an event processor, so that the users event processors receive the most diff --git a/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts index 735caf8d7788..9d3d4e4427fa 100644 --- a/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts @@ -39,7 +39,6 @@ export function wrapApiHandlerWithSentry( normalizedRequest: winterCGRequestToRequestData(req), }); currentScope.setTransactionName(`${req.method} ${parameterizedRoute}`); - headerAttributes = addHeadersAsAttributes(req.headers); } else { currentScope.setTransactionName(`handler (${parameterizedRoute})`); diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 5866f014ec69..5ce23e6a9460 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -36,6 +36,7 @@ import { TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL, TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, } from '../common/span-attributes-with-logic-attached'; +import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; import { isBuild } from '../common/utils/isBuild'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; @@ -163,13 +164,13 @@ export function init(options: NodeOptions): NodeClient | undefined { client?.on('spanStart', span => { const spanAttributes = spanToJSON(span).data; + const rootSpan = getRootSpan(span); + const isRootSpan = span === rootSpan; // What we do in this glorious piece of code, is hoist any information about parameterized routes from spans emitted // by Next.js via the `next.route` attribute, up to the transaction by setting the http.route attribute. if (typeof spanAttributes?.['next.route'] === 'string') { - const rootSpan = getRootSpan(span); const rootSpanAttributes = spanToJSON(rootSpan).data; - // Only hoist the http.route attribute if the transaction doesn't already have it if ( // eslint-disable-next-line deprecation/deprecation @@ -190,8 +191,13 @@ export function init(options: NodeOptions): NodeClient | undefined { span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto'); } + if (isRootSpan) { + const headers = getIsolationScope().getScopeData().sdkProcessingMetadata?.normalizedRequest?.headers; + addHeadersAsAttributes(headers, rootSpan); + } + // We want to fork the isolation scope for incoming requests - if (spanAttributes?.['next.span_type'] === 'BaseServer.handleRequest' && span === getRootSpan(span)) { + if (spanAttributes?.['next.span_type'] === 'BaseServer.handleRequest' && isRootSpan) { const scopes = getCapturedScopesOnSpan(span); const isolationScope = (scopes.isolationScope || getIsolationScope()).clone(); From 51a9defbcf08a90fd1c4c395b9af89c478c53420 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 2 Oct 2025 14:26:27 +0200 Subject: [PATCH 08/15] fix(core): Keep all property values in baggage header (#17847) https://www.w3.org/TR/baggage/#example contains an example where properties can include an `=`, e.g. `key3=value3; propertyKey=propertyValue` This PR updates our baggage parsing to not swallow these property values closes https://github.com/getsentry/sentry-javascript/issues/17845 --- .../fetch-baggage-property-values/subject.js | 9 ++++ .../template.html | 11 +++++ .../fetch-baggage-property-values/test.ts | 41 +++++++++++++++++++ .../xhr-baggage-property-values/subject.js | 8 ++++ .../xhr-baggage-property-values/test.ts | 36 ++++++++++++++++ .../baggage-property-values/server.ts | 37 +++++++++++++++++ .../baggage-property-values/test.ts | 28 +++++++++++++ packages/core/src/utils/baggage.ts | 15 +++++-- packages/core/test/lib/utils/baggage.test.ts | 12 ++++++ 9 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/fetch-baggage-property-values/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/fetch-baggage-property-values/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/fetch-baggage-property-values/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/xhr-baggage-property-values/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/xhr-baggage-property-values/test.ts create mode 100644 dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-property-values/server.ts create mode 100644 dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-property-values/test.ts diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-baggage-property-values/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-baggage-property-values/subject.js new file mode 100644 index 000000000000..76fafc9df148 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-baggage-property-values/subject.js @@ -0,0 +1,9 @@ +fetchButton.addEventListener('click', () => { + // W3C spec example: property values can contain = signs + // See: https://www.w3.org/TR/baggage/#example + fetch('http://sentry-test-site.example/fetch-test', { + headers: { + baggage: 'key1=value1;property1;property2,key2=value2,key3=value3; propertyKey=propertyValue', + }, + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-baggage-property-values/template.html b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-baggage-property-values/template.html new file mode 100644 index 000000000000..404eee952355 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-baggage-property-values/template.html @@ -0,0 +1,11 @@ + + + + + + Fetch Baggage Property Values Test + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-baggage-property-values/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-baggage-property-values/test.ts new file mode 100644 index 000000000000..c191304ec8e0 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-baggage-property-values/test.ts @@ -0,0 +1,41 @@ +import { expect } from '@playwright/test'; +import { TRACEPARENT_REGEXP } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest( + 'preserves baggage property values with equal signs in fetch requests', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const requestPromise = page.waitForRequest('http://sentry-test-site.example/fetch-test'); + + await page.goto(url); + await page.click('#fetchButton'); + + const request = await requestPromise; + + const requestHeaders = request.headers(); + + expect(requestHeaders).toMatchObject({ + 'sentry-trace': expect.stringMatching(TRACEPARENT_REGEXP), + }); + + const baggageHeader = requestHeaders.baggage; + expect(baggageHeader).toBeDefined(); + + const baggageItems = baggageHeader.split(',').map(item => decodeURIComponent(item.trim())); + + // Verify property values with = signs are preserved + expect(baggageItems).toContainEqual(expect.stringContaining('key1=value1;property1;property2')); + expect(baggageItems).toContainEqual(expect.stringContaining('key2=value2')); + expect(baggageItems).toContainEqual(expect.stringContaining('key3=value3; propertyKey=propertyValue')); + + // Verify Sentry baggage is also present + expect(baggageHeader).toMatch(/sentry-/); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-baggage-property-values/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-baggage-property-values/subject.js new file mode 100644 index 000000000000..839cdf137fd7 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-baggage-property-values/subject.js @@ -0,0 +1,8 @@ +const xhr = new XMLHttpRequest(); + +xhr.open('GET', 'http://sentry-test-site.example/1'); +// W3C spec example: property values can contain = signs +// See: https://www.w3.org/TR/baggage/#example +xhr.setRequestHeader('baggage', 'key1=value1;property1;property2,key2=value2,key3=value3; propertyKey=propertyValue'); + +xhr.send(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-baggage-property-values/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-baggage-property-values/test.ts new file mode 100644 index 000000000000..f2ac4edb4a67 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-baggage-property-values/test.ts @@ -0,0 +1,36 @@ +import { expect } from '@playwright/test'; +import { TRACEPARENT_REGEXP } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest('preserves baggage property values with equal signs in XHR requests', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const requestPromise = page.waitForRequest('http://sentry-test-site.example/1'); + + await page.goto(url); + + const request = await requestPromise; + + const requestHeaders = request.headers(); + + expect(requestHeaders).toMatchObject({ + 'sentry-trace': expect.stringMatching(TRACEPARENT_REGEXP), + }); + + const baggageHeader = requestHeaders.baggage; + expect(baggageHeader).toBeDefined(); + const baggageItems = baggageHeader.split(',').map(item => decodeURIComponent(item.trim())); + + // Verify property values with = signs are preserved + expect(baggageItems).toContainEqual(expect.stringContaining('key1=value1;property1;property2')); + expect(baggageItems).toContainEqual(expect.stringContaining('key2=value2')); + expect(baggageItems).toContainEqual(expect.stringContaining('key3=value3; propertyKey=propertyValue')); + + // Verify Sentry baggage is also present + expect(baggageHeader).toMatch(/sentry-/); +}); diff --git a/dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-property-values/server.ts b/dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-property-values/server.ts new file mode 100644 index 000000000000..da278ce61688 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-property-values/server.ts @@ -0,0 +1,37 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; + +export type TestAPIResponse = { test_data: { host: string; 'sentry-trace': string; baggage: string } }; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + environment: 'prod', + // disable requests to /express + tracePropagationTargets: [/^(?!.*express).*$/], + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +import cors from 'cors'; +import express from 'express'; +import http from 'http'; + +const app = express(); + +app.use(cors()); + +app.get('/test/express-property-values', (req, res) => { + const incomingBaggage = req.headers.baggage; + + // Forward the incoming baggage (which contains property values) to the outgoing request + // This tests that property values with = signs are preserved during parsing and re-serialization + const headers = http.get({ hostname: 'somewhere.not.sentry', headers: { baggage: incomingBaggage } }).getHeaders(); + + // Responding with the headers outgoing request headers back to the assertions. + res.send({ test_data: headers }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-property-values/test.ts b/dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-property-values/test.ts new file mode 100644 index 000000000000..23848d36a3df --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-property-values/test.ts @@ -0,0 +1,28 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; +import type { TestAPIResponse } from './server'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should preserve baggage property values with equal signs (W3C spec compliance)', async () => { + const runner = createRunner(__dirname, 'server.ts').start(); + + // W3C spec example: https://www.w3.org/TR/baggage/#example + const response = await runner.makeRequest('get', '/test/express-property-values', { + headers: { + 'sentry-trace': '12312012123120121231201212312012-1121201211212012-1', + baggage: 'key1=value1;property1;property2,key2=value2,key3=value3; propertyKey=propertyValue', + }, + }); + + expect(response).toBeDefined(); + + // The baggage should be parsed and re-serialized, preserving property values with = signs + const baggageItems = response?.test_data.baggage?.split(',').map(item => decodeURIComponent(item.trim())); + + expect(baggageItems).toContain('key1=value1;property1;property2'); + expect(baggageItems).toContain('key2=value2'); + expect(baggageItems).toContain('key3=value3; propertyKey=propertyValue'); +}); diff --git a/packages/core/src/utils/baggage.ts b/packages/core/src/utils/baggage.ts index b483207ba8f2..e94bb3d896e6 100644 --- a/packages/core/src/utils/baggage.ts +++ b/packages/core/src/utils/baggage.ts @@ -113,8 +113,15 @@ export function parseBaggageHeader( function baggageHeaderToObject(baggageHeader: string): Record { return baggageHeader .split(',') - .map(baggageEntry => - baggageEntry.split('=').map(keyOrValue => { + .map(baggageEntry => { + const eqIdx = baggageEntry.indexOf('='); + if (eqIdx === -1) { + // Likely an invalid entry + return []; + } + const key = baggageEntry.slice(0, eqIdx); + const value = baggageEntry.slice(eqIdx + 1); + return [key, value].map(keyOrValue => { try { return decodeURIComponent(keyOrValue.trim()); } catch { @@ -122,8 +129,8 @@ function baggageHeaderToObject(baggageHeader: string): Record { // This will then be skipped in the next step return; } - }), - ) + }); + }) .reduce>((acc, [key, value]) => { if (key && value) { acc[key] = value; diff --git a/packages/core/test/lib/utils/baggage.test.ts b/packages/core/test/lib/utils/baggage.test.ts index 4816a3fbf079..f3717a524bf8 100644 --- a/packages/core/test/lib/utils/baggage.test.ts +++ b/packages/core/test/lib/utils/baggage.test.ts @@ -71,4 +71,16 @@ describe('parseBaggageHeader', () => { const actual = parseBaggageHeader(input); expect(actual).toStrictEqual(expectedOutput); }); + + test('should preserve property values with equal signs', () => { + // see https://www.w3.org/TR/baggage/#example + const baggageHeader = 'key1=value1;property1;property2, key2 = value2, key3=value3; propertyKey=propertyValue'; + const result = parseBaggageHeader(baggageHeader); + + expect(result).toStrictEqual({ + key1: 'value1;property1;property2', + key2: 'value2', + key3: 'value3; propertyKey=propertyValue', + }); + }); }); From e88c08465d0562491f23cf74ab6a9c3b04a67e41 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 2 Oct 2025 14:57:48 +0100 Subject: [PATCH 09/15] feat(node): `pino` integration (#17584) - Adds `@apm-js-collab/tracing-hooks` as a dependency - Integrations can register instrumentations which causes the `@apm-js-collab/tracing-hooks` ESM hook and require patching to be initialised later - The `@apm-js-collab/*` dependencies only get included in a bundle when they are used by an integration - Adds a `pinoIntegration` that: - Is not enabled by default (see below) - Registers where it needs code injecting into the pino library - Hooks the tracing channel events, including the channel added to `pino@9.10.0` - Captures Sentry logs for pino logs - Captures in the correct tracing context because this is all sync! - Captures exception/message events for the configured `eventLevels` ## Supported Node versions We can't enable this integration by default because `TracingChannel`, injected by `@apm-js-collab/code-transformer` requires Node >= v19.9.0 or v18.19.0. --- .../scripts/consistentExports.ts | 1 + .../node-integration-tests/package.json | 2 + .../suites/pino/instrument.mjs | 8 + .../suites/pino/scenario-next.mjs | 18 ++ .../suites/pino/scenario.mjs | 18 ++ .../suites/pino/test.ts | 172 ++++++++++++++++++ packages/astro/src/index.server.ts | 1 + packages/aws-serverless/src/index.ts | 1 + packages/core/src/utils/worldwide.ts | 2 + packages/google-cloud-serverless/src/index.ts | 1 + packages/node-core/package.json | 2 + packages/node-core/src/index.ts | 1 + packages/node-core/src/integrations/pino.ts | 149 +++++++++++++++ .../src/sdk/apm-js-collab-tracing-hooks.d.ts | 11 ++ packages/node-core/src/sdk/index.ts | 3 + packages/node-core/src/sdk/injectLoader.ts | 46 +++++ packages/node/src/index.ts | 1 + yarn.lock | 52 +++++- 18 files changed, 481 insertions(+), 8 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/pino/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/pino/scenario-next.mjs create mode 100644 dev-packages/node-integration-tests/suites/pino/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/pino/test.ts create mode 100644 packages/node-core/src/integrations/pino.ts create mode 100644 packages/node-core/src/sdk/apm-js-collab-tracing-hooks.d.ts create mode 100644 packages/node-core/src/sdk/injectLoader.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 596109c0a596..17c6f714c499 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 @@ -53,6 +53,7 @@ const DEPENDENTS: Dependent[] = [ 'NODE_VERSION', 'childProcessIntegration', 'systemErrorIntegration', + 'pinoIntegration', ], }, { diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 48073f2ac817..e7e811b40d14 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -66,6 +66,8 @@ "node-schedule": "^2.1.1", "openai": "5.18.1", "pg": "8.16.0", + "pino": "9.9.4", + "pino-next": "npm:pino@^9.12.0", "postgres": "^3.4.7", "prisma": "6.15.0", "proxy": "^2.1.1", diff --git a/dev-packages/node-integration-tests/suites/pino/instrument.mjs b/dev-packages/node-integration-tests/suites/pino/instrument.mjs new file mode 100644 index 000000000000..2c09097de1f4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/pino/instrument.mjs @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + release: '1.0', + enableLogs: true, + integrations: [Sentry.pinoIntegration({ error: { levels: ['error', 'fatal'] } })], +}); diff --git a/dev-packages/node-integration-tests/suites/pino/scenario-next.mjs b/dev-packages/node-integration-tests/suites/pino/scenario-next.mjs new file mode 100644 index 000000000000..11fc038fea3a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/pino/scenario-next.mjs @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/node'; +import pino from 'pino-next'; + +const logger = pino({}); + +Sentry.withIsolationScope(() => { + Sentry.startSpan({ name: 'startup' }, () => { + logger.info({ user: 'user-id', something: { more: 3, complex: 'nope' } }, 'hello world'); + }); +}); + +setTimeout(() => { + Sentry.withIsolationScope(() => { + Sentry.startSpan({ name: 'later' }, () => { + logger.error(new Error('oh no')); + }); + }); +}, 1000); diff --git a/dev-packages/node-integration-tests/suites/pino/scenario.mjs b/dev-packages/node-integration-tests/suites/pino/scenario.mjs new file mode 100644 index 000000000000..3ff6c0b5e08d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/pino/scenario.mjs @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/node'; +import pino from 'pino'; + +const logger = pino({}); + +Sentry.withIsolationScope(() => { + Sentry.startSpan({ name: 'startup' }, () => { + logger.info({ user: 'user-id', something: { more: 3, complex: 'nope' } }, 'hello world'); + }); +}); + +setTimeout(() => { + Sentry.withIsolationScope(() => { + Sentry.startSpan({ name: 'later' }, () => { + logger.error(new Error('oh no')); + }); + }); +}, 1000); diff --git a/dev-packages/node-integration-tests/suites/pino/test.ts b/dev-packages/node-integration-tests/suites/pino/test.ts new file mode 100644 index 000000000000..15a9397ebb27 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/pino/test.ts @@ -0,0 +1,172 @@ +import { join } from 'path'; +import { expect, test } from 'vitest'; +import { conditionalTest } from '../../utils'; +import { createRunner } from '../../utils/runner'; + +conditionalTest({ min: 20 })('Pino integration', () => { + test('has different trace ids for logs from different spans', async () => { + const instrumentPath = join(__dirname, 'instrument.mjs'); + + await createRunner(__dirname, 'scenario.mjs') + .withMockSentryServer() + .withInstrument(instrumentPath) + .ignore('event') + .expect({ + log: log => { + const traceId1 = log.items?.[0]?.trace_id; + const traceId2 = log.items?.[1]?.trace_id; + expect(traceId1).not.toBe(traceId2); + }, + }) + .start() + .completed(); + }); + + test('captures event and logs', async () => { + const instrumentPath = join(__dirname, 'instrument.mjs'); + + await createRunner(__dirname, 'scenario.mjs') + .withMockSentryServer() + .withInstrument(instrumentPath) + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'oh no', + mechanism: { + type: 'pino', + handled: true, + }, + stacktrace: { + frames: expect.arrayContaining([ + expect.objectContaining({ + function: '?', + in_app: true, + module: 'scenario', + context_line: " logger.error(new Error('oh no'));", + }), + ]), + }, + }, + ], + }, + }, + }) + .expect({ + log: { + items: [ + { + timestamp: expect.any(Number), + level: 'info', + body: 'hello world', + trace_id: expect.any(String), + severity_number: 9, + attributes: expect.objectContaining({ + 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.pino.level': { value: 30, type: 'integer' }, + user: { value: 'user-id', type: 'string' }, + something: { + type: 'string', + value: '{"more":3,"complex":"nope"}', + }, + 'sentry.release': { value: '1.0', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + }), + }, + { + timestamp: expect.any(Number), + level: 'error', + body: 'oh no', + trace_id: expect.any(String), + severity_number: 17, + attributes: expect.objectContaining({ + 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.pino.level': { value: 50, type: 'integer' }, + err: { value: '{}', type: 'string' }, + 'sentry.release': { value: '1.0', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + }), + }, + ], + }, + }) + .start() + .completed(); + }); + + test('captures with Pino integrated channel', async () => { + const instrumentPath = join(__dirname, 'instrument.mjs'); + + await createRunner(__dirname, 'scenario-next.mjs') + .withMockSentryServer() + .withInstrument(instrumentPath) + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'oh no', + mechanism: { + type: 'pino', + handled: true, + }, + stacktrace: { + frames: expect.arrayContaining([ + expect.objectContaining({ + function: '?', + in_app: true, + module: 'scenario-next', + context_line: " logger.error(new Error('oh no'));", + }), + ]), + }, + }, + ], + }, + }, + }) + .expect({ + log: { + items: [ + { + timestamp: expect.any(Number), + level: 'info', + body: 'hello world', + trace_id: expect.any(String), + severity_number: 9, + attributes: expect.objectContaining({ + 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.pino.level': { value: 30, type: 'integer' }, + user: { value: 'user-id', type: 'string' }, + something: { + type: 'string', + value: '{"more":3,"complex":"nope"}', + }, + 'sentry.release': { value: '1.0', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + }), + }, + { + timestamp: expect.any(Number), + level: 'error', + body: 'oh no', + trace_id: expect.any(String), + severity_number: 17, + attributes: expect.objectContaining({ + 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.pino.level': { value: 50, type: 'integer' }, + err: { value: '{}', type: 'string' }, + 'sentry.release': { value: '1.0', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + }), + }, + ], + }, + }) + .start() + .completed(); + }); +}); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 790810e93797..f70d6e0a3573 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -94,6 +94,7 @@ export { onUnhandledRejectionIntegration, openAIIntegration, parameterize, + pinoIntegration, postgresIntegration, postgresJsIntegration, prismaIntegration, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index f7e72ec908ae..5a608a925edb 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -106,6 +106,7 @@ export { mysql2Integration, redisIntegration, tediousIntegration, + pinoIntegration, postgresIntegration, postgresJsIntegration, prismaIntegration, diff --git a/packages/core/src/utils/worldwide.ts b/packages/core/src/utils/worldwide.ts index e2f1ad5fc2b2..2eb7f39f3a24 100644 --- a/packages/core/src/utils/worldwide.ts +++ b/packages/core/src/utils/worldwide.ts @@ -48,6 +48,8 @@ export type InternalGlobal = { */ _sentryModuleMetadata?: Record; _sentryEsmLoaderHookRegistered?: boolean; + _sentryInjectLoaderHookRegister?: () => void; + _sentryInjectLoaderHookRegistered?: boolean; } & Carrier; /** Get's the global object for the current JavaScript runtime */ diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index bab9dc3a1cbb..8f1d236f7877 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -106,6 +106,7 @@ export { mysql2Integration, redisIntegration, tediousIntegration, + pinoIntegration, postgresIntegration, postgresJsIntegration, prismaIntegration, diff --git a/packages/node-core/package.json b/packages/node-core/package.json index 4e83faeb767d..ed90625bac44 100644 --- a/packages/node-core/package.json +++ b/packages/node-core/package.json @@ -68,9 +68,11 @@ "dependencies": { "@sentry/core": "10.17.0", "@sentry/opentelemetry": "10.17.0", + "@apm-js-collab/tracing-hooks": "^0.3.1", "import-in-the-middle": "^1.14.2" }, "devDependencies": { + "@apm-js-collab/code-transformer": "^0.8.2", "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^2.1.0", "@opentelemetry/core": "^2.1.0", diff --git a/packages/node-core/src/index.ts b/packages/node-core/src/index.ts index e6cf209d23f6..0f976bd23436 100644 --- a/packages/node-core/src/index.ts +++ b/packages/node-core/src/index.ts @@ -27,6 +27,7 @@ export { spotlightIntegration } from './integrations/spotlight'; export { systemErrorIntegration } from './integrations/systemError'; export { childProcessIntegration } from './integrations/childProcess'; export { createSentryWinstonTransport } from './integrations/winston'; +export { pinoIntegration } from './integrations/pino'; export { SentryContextManager } from './otel/contextManager'; export { setupOpenTelemetryLogger } from './otel/logger'; diff --git a/packages/node-core/src/integrations/pino.ts b/packages/node-core/src/integrations/pino.ts new file mode 100644 index 000000000000..af3f41735c4a --- /dev/null +++ b/packages/node-core/src/integrations/pino.ts @@ -0,0 +1,149 @@ +import { tracingChannel } from 'node:diagnostics_channel'; +import type { IntegrationFn, LogSeverityLevel } from '@sentry/core'; +import { + _INTERNAL_captureLog, + addExceptionMechanism, + captureException, + captureMessage, + defineIntegration, + severityLevelFromString, + withScope, +} from '@sentry/core'; +import { addInstrumentationConfig } from '../sdk/injectLoader'; + +type LevelMapping = { + // Fortunately pino uses the same levels as Sentry + labels: { [level: number]: LogSeverityLevel }; +}; + +type Pino = { + levels: LevelMapping; +}; + +type MergeObject = { + [key: string]: unknown; + err?: Error; +}; + +type PinoHookArgs = [MergeObject, string, number]; + +type PinoOptions = { + error: { + /** + * Levels that trigger capturing of events. + * + * @default [] + */ + levels: LogSeverityLevel[]; + /** + * By default, Sentry will mark captured errors as handled. + * Set this to `false` if you want to mark them as unhandled instead. + * + * @default true + */ + handled: boolean; + }; + log: { + /** + * Levels that trigger capturing of logs. Logs are only captured if + * `enableLogs` is enabled. + * + * @default ["trace", "debug", "info", "warn", "error", "fatal"] + */ + levels: LogSeverityLevel[]; + }; +}; + +const DEFAULT_OPTIONS: PinoOptions = { + error: { levels: [], handled: true }, + log: { levels: ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] }, +}; + +type DeepPartial = { + [P in keyof T]?: T[P] extends object ? Partial : T[P]; +}; + +/** + * Integration for Pino logging library. + * Captures Pino logs as Sentry logs and optionally captures some log levels as events. + * + * Requires Pino >=v8.0.0 and Node >=20.6.0 or >=18.19.0 + */ +export const pinoIntegration = defineIntegration((userOptions: DeepPartial = {}) => { + const options: PinoOptions = { + error: { ...DEFAULT_OPTIONS.error, ...userOptions.error }, + log: { ...DEFAULT_OPTIONS.log, ...userOptions.log }, + }; + + return { + name: 'Pino', + setup: client => { + const enableLogs = !!client.getOptions().enableLogs; + + addInstrumentationConfig({ + channelName: 'pino-log', + // From Pino v9.10.0 a tracing channel is available directly from Pino: + // https://github.com/pinojs/pino/pull/2281 + module: { name: 'pino', versionRange: '>=8.0.0 < 9.10.0', filePath: 'lib/tools.js' }, + functionQuery: { + functionName: 'asJson', + kind: 'Sync', + }, + }); + + const injectedChannel = tracingChannel('orchestrion:pino:pino-log'); + const integratedChannel = tracingChannel('pino_asJson'); + + function onPinoStart(self: Pino, args: PinoHookArgs): void { + const [obj, message, levelNumber] = args; + const level = self?.levels?.labels?.[levelNumber] || 'info'; + + const attributes = { + ...obj, + 'sentry.origin': 'auto.logging.pino', + 'sentry.pino.level': levelNumber, + }; + + if (enableLogs && options.log.levels.includes(level)) { + _INTERNAL_captureLog({ level, message, attributes }); + } + + if (options.error.levels.includes(level)) { + const captureContext = { + level: severityLevelFromString(level), + }; + + withScope(scope => { + scope.addEventProcessor(event => { + event.logger = 'pino'; + + addExceptionMechanism(event, { + handled: options.error.handled, + type: 'pino', + }); + + return event; + }); + + if (obj.err) { + captureException(obj.err, captureContext); + return; + } + + captureMessage(message, captureContext); + }); + } + } + + injectedChannel.start.subscribe(data => { + const { self, arguments: args } = data as { self: Pino; arguments: PinoHookArgs }; + onPinoStart(self, args); + }); + + integratedChannel.start.subscribe(data => { + const { instance, arguments: args } = data as { instance: Pino; arguments: PinoHookArgs }; + onPinoStart(instance, args); + }); + }, + }; +}) satisfies IntegrationFn; diff --git a/packages/node-core/src/sdk/apm-js-collab-tracing-hooks.d.ts b/packages/node-core/src/sdk/apm-js-collab-tracing-hooks.d.ts new file mode 100644 index 000000000000..c4ae4897678d --- /dev/null +++ b/packages/node-core/src/sdk/apm-js-collab-tracing-hooks.d.ts @@ -0,0 +1,11 @@ +declare module '@apm-js-collab/tracing-hooks' { + import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + + type PatchConfig = { instrumentations: InstrumentationConfig[] }; + + /** Hooks require */ + export default class ModulePatch { + public constructor(config: PatchConfig): ModulePatch; + public patch(): void; + } +} diff --git a/packages/node-core/src/sdk/index.ts b/packages/node-core/src/sdk/index.ts index c4a16d76a1d0..d53f5d4faefb 100644 --- a/packages/node-core/src/sdk/index.ts +++ b/packages/node-core/src/sdk/index.ts @@ -7,6 +7,7 @@ import { functionToStringIntegration, getCurrentScope, getIntegrationsToSetup, + GLOBAL_OBJ, hasSpansEnabled, inboundFiltersIntegration, linkedErrorsIntegration, @@ -131,6 +132,8 @@ function _init( client.init(); + GLOBAL_OBJ._sentryInjectLoaderHookRegister?.(); + debug.log(`SDK initialized from ${isCjs() ? 'CommonJS' : 'ESM'}`); client.startClientReportTracking(); diff --git a/packages/node-core/src/sdk/injectLoader.ts b/packages/node-core/src/sdk/injectLoader.ts new file mode 100644 index 000000000000..667996ebbe53 --- /dev/null +++ b/packages/node-core/src/sdk/injectLoader.ts @@ -0,0 +1,46 @@ +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import ModulePatch from '@apm-js-collab/tracing-hooks'; +import { debug, GLOBAL_OBJ } from '@sentry/core'; +import * as moduleModule from 'module'; +import { supportsEsmLoaderHooks } from '../utils/detection'; + +let instrumentationConfigs: InstrumentationConfig[] | undefined; + +/** + * Add an instrumentation config to be used by the injection loader. + */ +export function addInstrumentationConfig(config: InstrumentationConfig): void { + if (!supportsEsmLoaderHooks()) { + return; + } + + if (!instrumentationConfigs) { + instrumentationConfigs = []; + } + + instrumentationConfigs.push(config); + + GLOBAL_OBJ._sentryInjectLoaderHookRegister = () => { + if (GLOBAL_OBJ._sentryInjectLoaderHookRegistered) { + return; + } + + GLOBAL_OBJ._sentryInjectLoaderHookRegistered = true; + + const instrumentations = instrumentationConfigs || []; + + // Patch require to support CJS modules + const requirePatch = new ModulePatch({ instrumentations }); + requirePatch.patch(); + + // Add ESM loader to support ESM modules + try { + // @ts-expect-error register is available in these versions + moduleModule.register('@apm-js-collab/tracing-hooks/hook.mjs', import.meta.url, { + data: { instrumentations }, + }); + } catch (error) { + debug.warn("Failed to register '@apm-js-collab/tracing-hooks' hook", error); + } + }; +} diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 4808f22b472b..db378e55f6ca 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -171,6 +171,7 @@ export { disableAnrDetectionForCallback, spotlightIntegration, childProcessIntegration, + pinoIntegration, createSentryWinstonTransport, SentryContextManager, systemErrorIntegration, diff --git a/yarn.lock b/yarn.lock index 3a6b6375c015..2b7fc676accc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -335,6 +335,20 @@ dependencies: json-schema-to-ts "^3.1.1" +"@apm-js-collab/code-transformer@^0.8.0", "@apm-js-collab/code-transformer@^0.8.2": + version "0.8.2" + resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer/-/code-transformer-0.8.2.tgz#a3160f16d1c4df9cb81303527287ad18d00994d1" + integrity sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA== + +"@apm-js-collab/tracing-hooks@^0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.3.1.tgz#414d3a93c3a15d8be543a3fac561f7c602b6a588" + integrity sha512-Vu1CbmPURlN5fTboVuKMoJjbO5qcq9fA5YXpskx3dXe/zTBvjODFoerw+69rVBlRLrJpwPqSDqEuJDEKIrTldw== + dependencies: + "@apm-js-collab/code-transformer" "^0.8.0" + debug "^4.4.1" + module-details-from-path "^1.0.4" + "@apollo/protobufjs@1.2.6": version "1.2.6" resolved "https://registry.yarnpkg.com/@apollo/protobufjs/-/protobufjs-1.2.6.tgz#d601e65211e06ae1432bf5993a1a0105f2862f27" @@ -22453,10 +22467,10 @@ module-definition@^6.0.1: ast-module-types "^6.0.1" node-source-walk "^7.0.1" -module-details-from-path@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/module-details-from-path/-/module-details-from-path-1.0.3.tgz#114c949673e2a8a35e9d35788527aa37b679da2b" - integrity sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A== +module-details-from-path@^1.0.3, module-details-from-path@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/module-details-from-path/-/module-details-from-path-1.0.4.tgz#b662fdcd93f6c83d3f25289da0ce81c8d9685b94" + integrity sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w== module-lookup-amd@^8.0.5: version "8.0.5" @@ -24741,15 +24755,32 @@ pino-abstract-transport@^2.0.0: dependencies: split2 "^4.0.0" +"pino-next@npm:pino@^9.12.0": + version "9.12.0" + resolved "https://registry.yarnpkg.com/pino/-/pino-9.12.0.tgz#976e549cc29e21e5dbf56b47910dc52817dc5a97" + integrity sha512-0Gd0OezGvqtqMwgYxpL7P0pSHHzTJ0Lx992h+mNlMtRVfNnqweWmf0JmRWk5gJzHalyd2mxTzKjhiNbGS2Ztfw== + dependencies: + atomic-sleep "^1.0.0" + on-exit-leak-free "^2.1.0" + pino-abstract-transport "^2.0.0" + pino-std-serializers "^7.0.0" + process-warning "^5.0.0" + quick-format-unescaped "^4.0.3" + real-require "^0.2.0" + safe-stable-stringify "^2.3.1" + slow-redact "^0.3.0" + sonic-boom "^4.0.1" + thread-stream "^3.0.0" + pino-std-serializers@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz#7c625038b13718dbbd84ab446bd673dc52259e3b" integrity sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA== -pino@^9.0.0: - version "9.7.0" - resolved "https://registry.yarnpkg.com/pino/-/pino-9.7.0.tgz#ff7cd86eb3103ee620204dbd5ca6ffda8b53f645" - integrity sha512-vnMCM6xZTb1WDmLvtG2lE/2p+t9hDEIvTWJsu6FejkE62vB7gDhvzrpFR4Cw2to+9JNQxVnkAKVPA1KPB98vWg== +pino@9.9.4, pino@^9.0.0: + version "9.9.4" + resolved "https://registry.yarnpkg.com/pino/-/pino-9.9.4.tgz#21ed2c27cc177f797e3249c99d340f0bcd6b248e" + integrity sha512-d1XorUQ7sSKqVcYdXuEYs2h1LKxejSorMEJ76XoZ0pPDf8VzJMe7GlPXpMBZeQ9gE4ZPIp5uGD+5Nw7scxiigg== dependencies: atomic-sleep "^1.0.0" fast-redact "^3.1.1" @@ -27913,6 +27944,11 @@ sliced@1.0.1: resolved "https://registry.yarnpkg.com/sliced/-/sliced-1.0.1.tgz#0b3a662b5d04c3177b1926bea82b03f837a2ef41" integrity sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA== +slow-redact@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/slow-redact/-/slow-redact-0.3.0.tgz#97b4d7bd04136404e529c1ab29f3cb50e903c746" + integrity sha512-cf723wn9JeRIYP9tdtd86GuqoR5937u64Io+CYjlm2i7jvu7g0H+Cp0l0ShAf/4ZL+ISUTVT+8Qzz7RZmp9FjA== + smart-buffer@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" From 632f0b953d99050c11b0edafb9f80b5f3ba88045 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 2 Oct 2025 10:48:56 -0400 Subject: [PATCH 10/15] feat: Remove @sentry/pino-transport package (#17851) This package was added in https://github.com/getsentry/sentry-javascript/pull/16652, but with the merge of https://github.com/getsentry/sentry-javascript/pull/17584 we can remove it. We will recommend users use the `pinoIntegration` because it's way easier to set up and tracing works ootb. This package was never published (no craft config) so it's safe to remove. --- .github/ISSUE_TEMPLATE/bug.yml | 1 - .../e2e-tests/verdaccio-config/config.yaml | 6 - package.json | 1 - packages/pino-transport/.eslintrc.js | 12 - packages/pino-transport/LICENSE | 16 - packages/pino-transport/README.md | 266 ------- packages/pino-transport/package.json | 79 -- packages/pino-transport/rollup.npm.config.mjs | 3 - packages/pino-transport/src/debug-build.ts | 8 - packages/pino-transport/src/index.ts | 244 ------ packages/pino-transport/test/index.test.ts | 708 ------------------ packages/pino-transport/tsconfig.json | 10 - packages/pino-transport/tsconfig.test.json | 7 - packages/pino-transport/tsconfig.types.json | 10 - packages/pino-transport/vite.config.ts | 10 - yarn.lock | 2 +- 16 files changed, 1 insertion(+), 1382 deletions(-) delete mode 100644 packages/pino-transport/.eslintrc.js delete mode 100644 packages/pino-transport/LICENSE delete mode 100644 packages/pino-transport/README.md delete mode 100644 packages/pino-transport/package.json delete mode 100644 packages/pino-transport/rollup.npm.config.mjs delete mode 100644 packages/pino-transport/src/debug-build.ts delete mode 100644 packages/pino-transport/src/index.ts delete mode 100644 packages/pino-transport/test/index.test.ts delete mode 100644 packages/pino-transport/tsconfig.json delete mode 100644 packages/pino-transport/tsconfig.test.json delete mode 100644 packages/pino-transport/tsconfig.types.json delete mode 100644 packages/pino-transport/vite.config.ts diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 64709dd1f999..8acac6fd2709 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -51,7 +51,6 @@ body: - '@sentry/nestjs' - '@sentry/nextjs' - '@sentry/nuxt' - - '@sentry/pino-transport' - '@sentry/react' - '@sentry/react-router' - '@sentry/remix' diff --git a/dev-packages/e2e-tests/verdaccio-config/config.yaml b/dev-packages/e2e-tests/verdaccio-config/config.yaml index 1d565dbecda2..0773603b033e 100644 --- a/dev-packages/e2e-tests/verdaccio-config/config.yaml +++ b/dev-packages/e2e-tests/verdaccio-config/config.yaml @@ -122,12 +122,6 @@ packages: unpublish: $all # proxy: npmjs # Don't proxy for E2E tests! - '@sentry/pino-transport': - access: $all - publish: $all - unpublish: $all - # proxy: npmjs # Don't proxy for E2E tests! - '@sentry/profiling-node': access: $all publish: $all diff --git a/package.json b/package.json index e74379564683..edbd645b3c97 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,6 @@ "packages/node-native", "packages/nuxt", "packages/opentelemetry", - "packages/pino-transport", "packages/profiling-node", "packages/react", "packages/react-router", diff --git a/packages/pino-transport/.eslintrc.js b/packages/pino-transport/.eslintrc.js deleted file mode 100644 index 01c6be4c7080..000000000000 --- a/packages/pino-transport/.eslintrc.js +++ /dev/null @@ -1,12 +0,0 @@ -module.exports = { - env: { - node: true, - }, - extends: ['../../.eslintrc.js'], - overrides: [ - { - files: ['src/**/*.ts'], - rules: {}, - }, - ], -}; diff --git a/packages/pino-transport/LICENSE b/packages/pino-transport/LICENSE deleted file mode 100644 index 5251db3eaaca..000000000000 --- a/packages/pino-transport/LICENSE +++ /dev/null @@ -1,16 +0,0 @@ -MIT License - -Copyright (c) 2025 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/pino-transport/README.md b/packages/pino-transport/README.md deleted file mode 100644 index fdc077aa2b01..000000000000 --- a/packages/pino-transport/README.md +++ /dev/null @@ -1,266 +0,0 @@ -

- - Sentry - -

- -# Official Sentry Pino Transport - -[![npm version](https://img.shields.io/npm/v/@sentry/solid.svg)](https://www.npmjs.com/package/@sentry/solid) -[![npm dm](https://img.shields.io/npm/dm/@sentry/solid.svg)](https://www.npmjs.com/package/@sentry/solid) -[![npm dt](https://img.shields.io/npm/dt/@sentry/solid.svg)](https://www.npmjs.com/package/@sentry/solid) - -**WARNING**: This transport is in a **pre-release alpha**. The API is unstable and may change at any time. - -A Pino transport for sending logs to Sentry using the Sentry JavaScript SDK. - -This transport forwards Pino logs to Sentry, allowing you to view and analyze your application logs alongside your errors and performance data in Sentry. - -## Installation - -```bash -npm install @sentry/pino-transport pino -# or -yarn add @sentry/pino-transport pino -# or -pnpm add @sentry/pino-transport pino -``` - -## Requirements - -- Node.js 18+ -- Pino v8 or v9 -- `@sentry/node` SDK with `enableLogs: true` - -## Setup - -First, make sure Sentry is initialized with logging enabled: - -```javascript -import * as Sentry from '@sentry/node'; - -Sentry.init({ - dsn: 'YOUR_DSN', - enableLogs: true, -}); -``` - -Then create a Pino logger with the Sentry transport: - -```javascript -import pino from 'pino'; - -const logger = pino({ - transport: { - target: '@sentry/pino-transport', - options: { - // Optional: filter which log levels to send to Sentry - levels: ['error', 'fatal'], // defaults to all levels - }, - }, -}); - -// Now your logs will be sent to Sentry -logger.info('This is an info message'); -logger.error('This is an error message'); -``` - -## Configuration Options - -The transport accepts the following options: - -### `logLevels` - -**Type:** `Array<'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'>` - -**Default:** `['trace', 'debug', 'info', 'warn', 'error', 'fatal']` (all log levels) - -Use this option to filter which log severity levels should be sent to Sentry. - -```javascript -const transport = pino.transport({ - target: '@sentry/pino-transport', - options: { - logLevels: ['warn', 'error', 'fatal'], // Only send warnings and above - }, -}); -``` - -## Log Level Mapping - -Pino log levels are automatically mapped to Sentry log severity levels: - -| Pino Level | Pino Numeric | Sentry Level | -| ---------- | ------------ | ------------ | -| trace | 10 | trace | -| debug | 20 | debug | -| info | 30 | info | -| warn | 40 | warn | -| error | 50 | error | -| fatal | 60 | fatal | - -### Custom Levels Support - -Custom numeric levels are mapped to Sentry levels using ranges, so levels like `11`, `23`, or `42` will map correctly: - -- `0-19` → `trace` -- `20-29` → `debug` -- `30-39` → `info` -- `40-49` → `warn` -- `50-59` → `error` -- `60+` → `fatal` - -```javascript -import pino from 'pino'; - -const logger = pino({ - customLevels: { - critical: 55, // Maps to 'fatal' (55+ range) - notice: 35, // Maps to 'warn' (35-44 range) - verbose: 11, // Maps to 'trace' (0-14 range) - }, - transport: { - target: '@sentry/pino-transport', - }, -}); - -logger.critical('Critical issue occurred'); // → Sent as 'fatal' to Sentry -logger.notice('Important notice'); // → Sent as 'warn' to Sentry -logger.verbose('Detailed information'); // → Sent as 'trace' to Sentry -``` - -#### Custom Level Attributes - -When using custom string levels, the original level name is preserved as `sentry.pino.level` attribute for better traceability: - -```javascript -// Log entry in Sentry will include: -// { -// level: 'warn', // Mapped Sentry level -// message: 'Audit event', -// attributes: { -// 'sentry.pino.level': 'audit', // Original custom level name -// 'sentry.origin': 'auto.logging.pino', -// // ... other log attributes -// } -// } -``` - -### Custom Message Key - -The transport respects Pino's `messageKey` configuration: - -```javascript -const logger = pino({ - messageKey: 'message', // Use 'message' instead of default 'msg' - transport: { - target: '@sentry/pino-transport', - }, -}); - -logger.info({ message: 'Hello world' }); // Works correctly with custom messageKey -``` - -### Nested Key Support - -The transport automatically supports Pino's `nestedKey` configuration, which is used to avoid property conflicts by nesting logged objects under a specific key. When `nestedKey` is configured, the transport flattens these nested properties using dot notation for better searchability in Sentry. - -```javascript -const logger = pino({ - nestedKey: 'payload', // Nest logged objects under 'payload' key - transport: { - target: '@sentry/pino-transport', - }, -}); - -const conflictingObject = { - level: 'hi', // Conflicts with Pino's level - time: 'never', // Conflicts with Pino's time - foo: 'bar', - userId: 123, -}; - -logger.info(conflictingObject); - -// Without nestedKey, this would cause property conflicts -// With nestedKey, Pino creates: { level: 30, time: 1234567890, payload: conflictingObject } -// The transport flattens it to: -// { -// level: 'info', -// message: undefined, -// attributes: { -// 'payload.level': 'hi', // Flattened nested properties -// 'payload.time': 'never', -// 'payload.foo': 'bar', -// 'payload.userId': 123, -// 'sentry.origin': 'auto.logging.pino', -// } -// } -``` - -This flattening ensures that no property conflicts occur between logged objects and Pino's internal properties. - -## Usage Examples - -### Basic Logging - -```javascript -import pino from 'pino'; - -const logger = pino({ - transport: { - target: '@sentry/pino-transport', - }, -}); - -logger.trace('Starting application'); -logger.debug('Debug information', { userId: 123 }); -logger.info('User logged in', { userId: 123, username: 'john_doe' }); -logger.warn('Deprecated API used', { endpoint: '/old-api' }); -logger.error('Database connection failed', { error: 'Connection timeout' }); -logger.fatal('Application crashed', { reason: 'Out of memory' }); -``` - -### Multiple Transports - -```javascript -import pino from 'pino'; - -const logger = pino({ - transport: { - targets: [ - { - target: 'pino-pretty', - options: { colorize: true }, - level: 'debug', - }, - { - target: '@sentry/pino-transport', - options: { - logLevels: ['warn', 'error', 'fatal'], - }, - level: 'warn', - }, - ], - }, -}); -``` - -## Troubleshooting - -### Logs not appearing in Sentry - -1. Ensure `enableLogs: true` is set in your Sentry configuration. -2. Check that your DSN is correct and the SDK is properly initialized. -3. Verify the log level is included in the `levels` configuration. -4. Check your Sentry organization stats page to see if logs are being received by Sentry. - -## Related Documentation - -- [Sentry Logs Documentation](https://docs.sentry.io/platforms/javascript/guides/node/logs/) -- [Pino Documentation](https://getpino.io/) -- [Pino Transports](https://getpino.io/#/docs/transports) - -## License - -MIT diff --git a/packages/pino-transport/package.json b/packages/pino-transport/package.json deleted file mode 100644 index 13c83d19b6ee..000000000000 --- a/packages/pino-transport/package.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "name": "@sentry/pino-transport", - "version": "10.17.0", - "description": "Pino transport for Sentry SDK", - "repository": "git://github.com/getsentry/sentry-javascript.git", - "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/pino-transport", - "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/core": "10.17.0", - "@sentry/node": "10.17.0", - "pino-abstract-transport": "^2.0.0" - }, - "peerDependencies": { - "pino": "^8.0.0 || ^9.0.0" - }, - "devDependencies": { - "@types/node": "^18.19.1", - "pino": "^9.0.0" - }, - "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:types:watch", - "build:dev:watch": "yarn build:watch", - "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch", - "build:types:watch": "tsc -p tsconfig.types.json --watch", - "build:tarball": "npm pack", - "circularDepCheck": "madge --circular src/index.ts", - "clean": "rimraf build coverage sentry-pino-transport-*.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": "yarn test:unit", - "test:unit": "vitest run", - "test:watch": "vitest --watch", - "yalc:publish": "yalc publish --push --sig" - }, - "volta": { - "extends": "../../package.json" - }, - "sideEffects": false -} diff --git a/packages/pino-transport/rollup.npm.config.mjs b/packages/pino-transport/rollup.npm.config.mjs deleted file mode 100644 index 84a06f2fb64a..000000000000 --- a/packages/pino-transport/rollup.npm.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; - -export default makeNPMConfigVariants(makeBaseNPMConfig()); diff --git a/packages/pino-transport/src/debug-build.ts b/packages/pino-transport/src/debug-build.ts deleted file mode 100644 index 60aa50940582..000000000000 --- a/packages/pino-transport/src/debug-build.ts +++ /dev/null @@ -1,8 +0,0 @@ -declare const __DEBUG_BUILD__: boolean; - -/** - * This serves as a build time flag that will be true by default, but false in non-debug builds or if users replace `__SENTRY_DEBUG__` in their generated code. - * - * ATTENTION: This constant must never cross package boundaries (i.e. be exported) to guarantee that it can be used for tree shaking. - */ -export const DEBUG_BUILD = __DEBUG_BUILD__; diff --git a/packages/pino-transport/src/index.ts b/packages/pino-transport/src/index.ts deleted file mode 100644 index 986c7e892fc2..000000000000 --- a/packages/pino-transport/src/index.ts +++ /dev/null @@ -1,244 +0,0 @@ -import type { LogSeverityLevel } from '@sentry/core'; -import { _INTERNAL_captureLog, debug, isPrimitive, normalize } from '@sentry/core'; -import type buildType from 'pino-abstract-transport'; -import * as pinoAbstractTransport from 'pino-abstract-transport'; -import { DEBUG_BUILD } from './debug-build'; - -// Handle both CommonJS and ES module exports -// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any -const build = (pinoAbstractTransport as any).default || pinoAbstractTransport; - -/** - * The default log levels that will be captured by the Sentry Pino transport. - */ -const DEFAULT_CAPTURED_LEVELS: Array = ['trace', 'debug', 'info', 'warn', 'error', 'fatal']; - -/** - * Options for the Sentry Pino transport. - */ -export interface SentryPinoTransportOptions { - /** - * Use this option to filter which levels should be captured as logs. - * By default, all levels are captured as logs. - * - * @example - * ```ts - * const logger = pino({ - * transport: { - * target: '@sentry/pino-transport', - * options: { - * logLevels: ['error', 'warn'], // Only capture error and warn logs - * }, - * }, - * }); - * ``` - */ - logLevels?: Array; -} - -/** - * Pino source configuration passed to the transport. - * This interface represents the configuration options that Pino provides to transports. - */ -interface PinoSourceConfig { - /** - * Custom levels configuration from Pino. - * Contains the mapping of custom level names to numeric values. - * - * @default undefined - * @example { values: { critical: 55, notice: 35 } } - */ - levels?: unknown; - - /** - * The property name used for the log message. - * Pino allows customizing which property contains the main log message. - * - * @default 'msg' - * @example 'message' when configured with messageKey: 'message' - * @see https://getpino.io/#/docs/api?id=messagekey-string - */ - messageKey?: string; - - /** - * The property name used for error objects. - * Pino allows customizing which property contains error information. - * - * @default 'err' - * @example 'error' when configured with errorKey: 'error' - * @see https://getpino.io/#/docs/api?id=errorkey-string - */ - errorKey?: string; - - /** - * The property name used to nest logged objects to avoid conflicts. - * When set, Pino nests all logged objects under this key to prevent - * conflicts with Pino's internal properties (level, time, pid, etc.). - * The transport flattens these nested properties using dot notation. - * - * @default undefined (no nesting) - * @example 'payload' - objects logged will be nested under { payload: {...} } - * @see https://getpino.io/#/docs/api?id=nestedkey-string - */ - nestedKey?: string; -} - -/** - * Creates a new Sentry Pino transport that forwards logs to Sentry. Requires the `enableLogs` option to be enabled. - * - * Supports Pino v8 and v9. - * - * @param options - Options for the transport. - * @returns A Pino transport that forwards logs to Sentry. - * - * @experimental This method will experience breaking changes. This is not yet part of - * the stable Sentry SDK API and can be changed or removed without warning. - */ -export function createSentryPinoTransport(options?: SentryPinoTransportOptions): ReturnType { - DEBUG_BUILD && debug.log('Initializing Sentry Pino transport'); - const capturedLogLevels = new Set(options?.logLevels ?? DEFAULT_CAPTURED_LEVELS); - - return build( - async function (source: AsyncIterable & PinoSourceConfig) { - for await (const log of source) { - try { - if (!isObject(log)) { - continue; - } - - // Use Pino's messageKey if available, fallback to 'msg' - const messageKey = source.messageKey || 'msg'; - const message = log[messageKey]; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { [messageKey]: _, level, time, ...attributes } = log; - - // Handle nestedKey flattening if configured - if (source.nestedKey && attributes[source.nestedKey] && isObject(attributes[source.nestedKey])) { - const nestedObject = attributes[source.nestedKey] as Record; - // Remove the nested object and flatten its properties - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete attributes[source.nestedKey]; - - // Flatten nested properties with dot notation - for (const [key, value] of Object.entries(nestedObject)) { - attributes[`${source.nestedKey}.${key}`] = value; - } - } - - const logSeverityLevel = mapPinoLevelToSentryLevel(log.level, source.levels); - - if (capturedLogLevels.has(logSeverityLevel)) { - const logAttributes: Record = { - ...attributes, - 'sentry.origin': 'auto.logging.pino', - }; - - // Attach custom level as an attribute if it's a string (custom level) - if (typeof log.level === 'string') { - logAttributes['sentry.pino.level'] = log.level; - } - - _INTERNAL_captureLog({ - level: logSeverityLevel, - message: formatMessage(message), - attributes: logAttributes, - }); - } - } catch { - // Silently ignore errors to prevent breaking the logging pipeline - } - } - }, - { - expectPinoConfig: true, - }, - ); -} - -function formatMessage(message: unknown): string { - if (message === undefined) { - return ''; - } - - if (isPrimitive(message)) { - return String(message); - } - return JSON.stringify(normalize(message)); -} - -/** - * Maps a Pino log level (numeric or custom string) to a Sentry log severity level. - * - * Handles both standard and custom levels, including when `useOnlyCustomLevels` is enabled. - * Uses range-based mapping for numeric levels to handle custom values (e.g., 11 -> trace). - */ -function mapPinoLevelToSentryLevel(level: unknown, levelsConfig?: unknown): LogSeverityLevel { - // Handle numeric levels - if (typeof level === 'number') { - return mapNumericLevelToSentryLevel(level); - } - - // Handle custom string levels - if ( - typeof level === 'string' && - isObject(levelsConfig) && - 'values' in levelsConfig && - isObject(levelsConfig.values) - ) { - // Map custom string levels to numeric then to Sentry levels - const numericLevel = levelsConfig.values[level]; - if (typeof numericLevel === 'number') { - return mapNumericLevelToSentryLevel(numericLevel); - } - } - - // Default fallback - return 'info'; -} - -/** - * Maps a numeric level to the closest Sentry severity level using range-based mapping. - * Handles both standard Pino levels and custom numeric levels. - * - * - `0-19` -> `trace` - * - `20-29` -> `debug` - * - `30-39` -> `info` - * - `40-49` -> `warn` - * - `50-59` -> `error` - * - `60+` -> `fatal` - * - * @see https://github.com/pinojs/pino/blob/116b1b17935630b97222fbfd1c053d199d18ca4b/lib/constants.js#L6-L13 - */ -function mapNumericLevelToSentryLevel(numericLevel: number): LogSeverityLevel { - // 0-19 -> trace - if (numericLevel < 20) { - return 'trace'; - } - // 20-29 -> debug - if (numericLevel < 30) { - return 'debug'; - } - // 30-39 -> info - if (numericLevel < 40) { - return 'info'; - } - // 40-49 -> warn - if (numericLevel < 50) { - return 'warn'; - } - // 50-59 -> error - if (numericLevel < 60) { - return 'error'; - } - // 60+ -> fatal - return 'fatal'; -} - -/** - * Type guard to check if a value is an object. - */ -function isObject(value: unknown): value is Record { - return typeof value === 'object' && value != null; -} - -export default createSentryPinoTransport; diff --git a/packages/pino-transport/test/index.test.ts b/packages/pino-transport/test/index.test.ts deleted file mode 100644 index a93d56f340cd..000000000000 --- a/packages/pino-transport/test/index.test.ts +++ /dev/null @@ -1,708 +0,0 @@ -import { _INTERNAL_captureLog } from '@sentry/core'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createSentryPinoTransport } from '../src'; - -// Mock the _INTERNAL_captureLog function -vi.mock('@sentry/core', async actual => { - const actualModule = (await actual()) as any; - return { - ...actualModule, - _INTERNAL_captureLog: vi.fn(), - }; -}); - -const mockCaptureLog = vi.mocked(_INTERNAL_captureLog); - -describe('createSentryPinoTransport', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - it('should be defined', () => { - expect(createSentryPinoTransport).toBeDefined(); - }); - - it('should create a transport that forwards logs to Sentry', async () => { - const transport = await createSentryPinoTransport(); - expect(transport).toBeDefined(); - expect(typeof transport.write).toBe('function'); - }); - - it('should capture logs with correct level mapping', async () => { - const transport = await createSentryPinoTransport(); - - // Simulate a Pino log entry - const testLog = { - level: 30, // info level in Pino - msg: 'Test message', - time: Date.now(), - hostname: 'test-host', - pid: 12345, - }; - - // Write the log to the transport - transport.write(`${JSON.stringify(testLog)}\n`); - - // Give it a moment to process - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: 'Test message', - attributes: expect.objectContaining({ - hostname: 'test-host', - pid: 12345, - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); - - it('should map all Pino log levels correctly', async () => { - const transport = await createSentryPinoTransport(); - - const testCases = [ - { pinoLevel: 10, expectedSentryLevel: 'trace' }, - { pinoLevel: 20, expectedSentryLevel: 'debug' }, - { pinoLevel: 30, expectedSentryLevel: 'info' }, - { pinoLevel: 40, expectedSentryLevel: 'warn' }, - { pinoLevel: 50, expectedSentryLevel: 'error' }, - { pinoLevel: 60, expectedSentryLevel: 'fatal' }, - ]; - - for (const { pinoLevel, expectedSentryLevel } of testCases) { - const testLog = { - level: pinoLevel, - msg: `Test ${expectedSentryLevel} message`, - time: Date.now(), - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - } - - // Give it a moment to process all logs - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledTimes(6); - - testCases.forEach(({ expectedSentryLevel }, index) => { - expect(mockCaptureLog).toHaveBeenNthCalledWith(index + 1, { - level: expectedSentryLevel, - message: `Test ${expectedSentryLevel} message`, - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); - }); - - it('should respect level filtering', async () => { - const transport = await createSentryPinoTransport({ - logLevels: ['error', 'fatal'], - }); - - const testLogs = [ - { level: 30, msg: 'Info message' }, // Should be filtered out - { level: 50, msg: 'Error message' }, // Should be captured - { level: 60, msg: 'Fatal message' }, // Should be captured - ]; - - for (const testLog of testLogs) { - transport.write(`${JSON.stringify(testLog)}\n`); - } - - // Give it a moment to process all logs - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledTimes(2); - expect(mockCaptureLog).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - level: 'error', - message: 'Error message', - }), - ); - expect(mockCaptureLog).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - level: 'fatal', - message: 'Fatal message', - }), - ); - }); - - it('should handle unknown levels gracefully', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 999, // Unknown level - msg: 'Unknown level message', - time: Date.now(), - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - // Give it a moment to process - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'fatal', // 999 maps to fatal (55+ range) - message: 'Unknown level message', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); - - it('should handle non-numeric levels gracefully', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 'invalid', // Non-numeric level - msg: 'Invalid level message', - time: Date.now(), - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - // Give it a moment to process - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', // Default fallback - message: 'Invalid level message', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - 'sentry.pino.level': 'invalid', - }), - }); - }); - - it('should handle malformed JSON gracefully', async () => { - const transport = await createSentryPinoTransport(); - - // Write invalid JSON - transport.write('{ invalid json \n'); - - // Give it a moment to process - await new Promise(resolve => setTimeout(resolve, 10)); - - // Should not crash and should not call captureLog - expect(mockCaptureLog).not.toHaveBeenCalled(); - }); - - it('should handle non-object logs gracefully', async () => { - const transport = await createSentryPinoTransport(); - - // Write a string instead of an object - transport.write('"just a string"\n'); - - // Give it a moment to process - await new Promise(resolve => setTimeout(resolve, 10)); - - // pino-abstract-transport parses JSON, so this actually becomes an object - // The transport should handle it gracefully by logging it - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', // Default fallback since no level provided - message: '', // Empty string for undefined message - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); - - it('should handle string levels gracefully when no custom levels config is available', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 'custom', // String level without custom levels config - msg: 'Custom string level message', - time: Date.now(), - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', // Should fallback to info for unknown string levels - message: 'Custom string level message', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - 'sentry.pino.level': 'custom', - }), - }); - }); - - it('should attach custom level name as attribute for string levels', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 'critical', // Custom string level - msg: 'Critical level message', - time: Date.now(), - userId: 123, - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', // Mapped level - message: 'Critical level message', - attributes: expect.objectContaining({ - userId: 123, - 'sentry.origin': 'auto.logging.pino', - 'sentry.pino.level': 'critical', // Original custom level name preserved - }), - }); - }); - - it('should not attach custom level attribute for numeric levels', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 30, // Standard numeric level - msg: 'Standard level message', - time: Date.now(), - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: 'Standard level message', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - // Should NOT have 'sentry.pino.level' for numeric levels - }), - }); - - // Explicitly check that the custom level attribute is not present - const capturedCall = mockCaptureLog.mock.calls[0][0]; - expect(capturedCall.attributes).not.toHaveProperty('sentry.pino.level'); - }); - - it('should handle custom numeric levels with range-based mapping', async () => { - const transport = await createSentryPinoTransport(); - - const testCases = [ - { level: 11, expectedSentryLevel: 'trace' }, // 11 is in trace range (0-14) - { level: 23, expectedSentryLevel: 'debug' }, // 23 is in debug range (15-24) - { level: 33, expectedSentryLevel: 'info' }, // 33 is in info range (25-34) - { level: 42, expectedSentryLevel: 'warn' }, // 42 is in warn range (35-44) - { level: 52, expectedSentryLevel: 'error' }, // 52 is in error range (45-54) - { level: 75, expectedSentryLevel: 'fatal' }, // 75 is in fatal range (55+) - ]; - - for (const { level } of testCases) { - const testLog = { - level, - msg: `Custom numeric level ${level}`, - time: Date.now(), - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - } - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledTimes(6); - - testCases.forEach(({ level, expectedSentryLevel }, index) => { - expect(mockCaptureLog).toHaveBeenNthCalledWith(index + 1, { - level: expectedSentryLevel, - message: `Custom numeric level ${level}`, - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); - }); - - it('should handle nested keys', async () => { - const transport = await createSentryPinoTransport(); - - // Test with logs that include a nested object structure as Pino would create - // when nestedKey is configured (we'll test by manually checking the flattening logic) - const testLog = { - level: 30, - msg: 'Test message with nested payload', - time: Date.now(), - payload: { - level: 'hi', // Conflicting with Pino's level - time: 'never', // Conflicting with Pino's time - foo: 'bar', - userId: 123, - }, - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - // Without nestedKey configuration, the nested object should remain as-is - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: 'Test message with nested payload', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - payload: { - level: 'hi', - time: 'never', - foo: 'bar', - userId: 123, - }, // Should remain nested without nestedKey config - }), - }); - }); - - it('should handle logs without conflicting nested objects', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 40, - msg: 'Warning with simple nested data', - time: Date.now(), - data: { - errorCode: 'E001', - module: 'auth', - details: 'Invalid credentials', - }, - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'warn', - message: 'Warning with simple nested data', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - data: { - errorCode: 'E001', - module: 'auth', - details: 'Invalid credentials', - }, // Should remain as nested object - }), - }); - }); - - it('should handle logs with multiple nested objects', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 30, - msg: 'Test message with multiple nested objects', - time: Date.now(), - user: { - id: 123, - name: 'John Doe', - }, - request: { - method: 'POST', - url: '/api/users', - headers: { - 'content-type': 'application/json', - }, - }, - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: 'Test message with multiple nested objects', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - user: { - id: 123, - name: 'John Doe', - }, - request: { - method: 'POST', - url: '/api/users', - headers: { - 'content-type': 'application/json', - }, - }, - }), - }); - }); - - it('should handle null nested objects', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 30, - msg: 'Test message with null values', - time: Date.now(), - data: null, - user: undefined, - config: { - setting: null, - }, - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: 'Test message with null values', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - data: null, - config: { - setting: null, - }, - }), - }); - }); - - it('should work normally with mixed data types', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 30, - msg: 'Mixed data types log', - time: Date.now(), - stringValue: 'test', - numberValue: 42, - booleanValue: true, - arrayValue: [1, 2, 3], - objectValue: { nested: 'value' }, - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: 'Mixed data types log', - attributes: expect.objectContaining({ - stringValue: 'test', - numberValue: 42, - booleanValue: true, - arrayValue: [1, 2, 3], - objectValue: { nested: 'value' }, - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); - - it('should handle string messages', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 30, - msg: 'This is a string message', - time: Date.now(), - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: 'This is a string message', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); - - it('should handle number messages', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 30, - msg: 42, - time: Date.now(), - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: '42', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); - - it('should handle boolean messages', async () => { - const transport = await createSentryPinoTransport(); - - const testCases = [{ msg: true }, { msg: false }]; - - for (const { msg } of testCases) { - const testLog = { - level: 30, - msg, - time: Date.now(), - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - } - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledTimes(2); - expect(mockCaptureLog).toHaveBeenNthCalledWith(1, { - level: 'info', - message: 'true', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - }), - }); - expect(mockCaptureLog).toHaveBeenNthCalledWith(2, { - level: 'info', - message: 'false', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); - - it('should handle null and undefined messages', async () => { - const transport = await createSentryPinoTransport(); - - const testCases = [{ msg: null }, { msg: undefined }]; - - for (const { msg } of testCases) { - const testLog = { - level: 30, - msg, - time: Date.now(), - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - } - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledTimes(2); - expect(mockCaptureLog).toHaveBeenNthCalledWith(1, { - level: 'info', - message: 'null', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - }), - }); - expect(mockCaptureLog).toHaveBeenNthCalledWith(2, { - level: 'info', - message: '', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); - - it('should handle object messages', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 30, - msg: { key: 'value', nested: { prop: 123 } }, - time: Date.now(), - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: '{"key":"value","nested":{"prop":123}}', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); - - it('should handle array messages', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 30, - msg: [1, 'two', { three: 3 }], - time: Date.now(), - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: '[1,"two",{"three":3}]', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); - - it('should handle circular object messages gracefully', async () => { - const transport = await createSentryPinoTransport(); - - // Create a test log with a circular object as the message - // We can't use JSON.stringify directly, so we'll simulate what happens - const testLog = { - level: 30, - msg: { name: 'test', circular: true }, // Simplified object that represents circular data - time: Date.now(), - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: '{"name":"test","circular":true}', // The object should be serialized normally - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); - - it('should handle missing message gracefully', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 30, - // No msg property - time: Date.now(), - someOtherData: 'value', - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: '', // Empty string for undefined message - attributes: expect.objectContaining({ - someOtherData: 'value', - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); -}); diff --git a/packages/pino-transport/tsconfig.json b/packages/pino-transport/tsconfig.json deleted file mode 100644 index 64d6f3a1b9e0..000000000000 --- a/packages/pino-transport/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.json", - - "include": ["src/**/*"], - - "compilerOptions": { - "lib": ["es2020"], - "module": "Node16" - } -} diff --git a/packages/pino-transport/tsconfig.test.json b/packages/pino-transport/tsconfig.test.json deleted file mode 100644 index 4c24dbbea96e..000000000000 --- a/packages/pino-transport/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./tsconfig.json", - "include": ["test/**/*", "src/**/*", "vite.config.ts"], - "compilerOptions": { - "types": ["vitest/globals", "node"] - } -} diff --git a/packages/pino-transport/tsconfig.types.json b/packages/pino-transport/tsconfig.types.json deleted file mode 100644 index f35cdd6b5d81..000000000000 --- a/packages/pino-transport/tsconfig.types.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "build/types", - "declaration": true, - "declarationMap": true, - "emitDeclarationOnly": true, - "stripInternal": true - } -} diff --git a/packages/pino-transport/vite.config.ts b/packages/pino-transport/vite.config.ts deleted file mode 100644 index ff64487a9265..000000000000 --- a/packages/pino-transport/vite.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from 'vitest/config'; -import baseConfig from '../../vite/vite.config'; - -export default defineConfig({ - ...baseConfig, - test: { - ...baseConfig.test, - environment: 'node', - }, -}); diff --git a/yarn.lock b/yarn.lock index 2b7fc676accc..4d084a8cf3c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -24777,7 +24777,7 @@ pino-std-serializers@^7.0.0: resolved "https://registry.yarnpkg.com/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz#7c625038b13718dbbd84ab446bd673dc52259e3b" integrity sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA== -pino@9.9.4, pino@^9.0.0: +pino@9.9.4: version "9.9.4" resolved "https://registry.yarnpkg.com/pino/-/pino-9.9.4.tgz#21ed2c27cc177f797e3249c99d340f0bcd6b248e" integrity sha512-d1XorUQ7sSKqVcYdXuEYs2h1LKxejSorMEJ76XoZ0pPDf8VzJMe7GlPXpMBZeQ9gE4ZPIp5uGD+5Nw7scxiigg== From a2669098f07184c95b3e17486e790bdf5119bb19 Mon Sep 17 00:00:00 2001 From: Stefan van der Wolf Date: Mon, 6 Oct 2025 10:44:01 +0200 Subject: [PATCH 11/15] fix(nestjs): Add support for Symbol as event name (#17785) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `@OnEvent` decorator accepts the following types for its argument: ```typescript string | symbol | Array ``` If a Symbol is included in an array, the code to get the eventName will throw a TypeError (String(event)) This occurs because JavaScript’s Array.prototype.join internally calls ToString on each array element. Per the specification, ToString(Symbol) is not allowed and results in a TypeError. To avoid this issue, do not rely on String(array) or .join() on arrays containing symbols directly. Instead, explicitly convert each element to a string while handling symbols safely. I couldn't find a way to test adding multiple `@OnEvent` so the second part can be tested. It didn't have any tests before (as far as I can tell), but would be nice to add them. doc: https://tc39.es/ecma262/multipage/indexed-collections.html#sec-array.prototype.join doc: https://tc39.es/ecma262/multipage/abstract-operations.html#sec-tostring --- .../sentry-nest-event-instrumentation.ts | 12 +++- .../nestjs/test/integrations/nest.test.ts | 61 ++++++++++++++++++- 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts index f8076087fd5d..92c90c3719de 100644 --- a/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts +++ b/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts @@ -74,10 +74,18 @@ export class SentryNestEventInstrumentation extends InstrumentationBase { return decoratorResult(target, propertyKey, descriptor); } + function eventNameFromEvent(event: unknown): string { + if (typeof event === 'string') { + return event; + } else if (Array.isArray(event)) { + return event.map(eventNameFromEvent).join(','); + } else return String(event); + } + const originalHandler = descriptor.value; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const handlerName = originalHandler.name || propertyKey; - let eventName = typeof event === 'string' ? event : String(event); + let eventName = eventNameFromEvent(event); // Instrument the actual handler descriptor.value = async function (...args: unknown[]) { @@ -93,7 +101,7 @@ export class SentryNestEventInstrumentation extends InstrumentationBase { eventName = eventData .map((data: unknown) => { if (data && typeof data === 'object' && 'event' in data && data.event) { - return data.event; + return eventNameFromEvent(data.event); } return ''; }) diff --git a/packages/nestjs/test/integrations/nest.test.ts b/packages/nestjs/test/integrations/nest.test.ts index 69fb022441dd..2d1d73b4657a 100644 --- a/packages/nestjs/test/integrations/nest.test.ts +++ b/packages/nestjs/test/integrations/nest.test.ts @@ -75,17 +75,72 @@ describe('Nest', () => { await descriptor.value(); - expect(core.startSpan).toHaveBeenCalled(); + expect(core.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'event test.event', + }), + expect.any(Function), + ); expect(originalHandler).toHaveBeenCalled(); }); - it('should wrap array event handlers', async () => { + it('should wrap symbol event handlers', async () => { + const decorated = wrappedOnEvent(Symbol('test.event')); + decorated(mockTarget, 'testMethod', descriptor); + + await descriptor.value(); + + expect(core.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'event Symbol(test.event)', + }), + expect.any(Function), + ); + expect(originalHandler).toHaveBeenCalled(); + }); + + it('should wrap string array event handlers', async () => { const decorated = wrappedOnEvent(['test.event1', 'test.event2']); decorated(mockTarget, 'testMethod', descriptor); await descriptor.value(); - expect(core.startSpan).toHaveBeenCalled(); + expect(core.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'event test.event1,test.event2', + }), + expect.any(Function), + ); + expect(originalHandler).toHaveBeenCalled(); + }); + + it('should wrap symbol array event handlers', async () => { + const decorated = wrappedOnEvent([Symbol('test.event1'), Symbol('test.event2')]); + decorated(mockTarget, 'testMethod', descriptor); + + await descriptor.value(); + + expect(core.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'event Symbol(test.event1),Symbol(test.event2)', + }), + expect.any(Function), + ); + expect(originalHandler).toHaveBeenCalled(); + }); + + it('should wrap mixed type array event handlers', async () => { + const decorated = wrappedOnEvent([Symbol('test.event1'), 'test.event2', Symbol('test.event3')]); + decorated(mockTarget, 'testMethod', descriptor); + + await descriptor.value(); + + expect(core.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'event Symbol(test.event1),test.event2,Symbol(test.event3)', + }), + expect.any(Function), + ); expect(originalHandler).toHaveBeenCalled(); }); From 2dbcfedb3cce60178e6c793e8b66a911c1c2778e Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Mon, 6 Oct 2025 11:20:20 +0200 Subject: [PATCH 12/15] feat(core): Rename vercelai.schema to gen_ai.request.schema (#17850) Renaming vercelai schema to genai schema for Vercel AI integration. --- .../vercelai/scenario-generate-object.mjs | 30 +++++++++ .../tracing/vercelai/test-generate-object.ts | 67 +++++++++++++++++++ packages/core/src/utils/vercel-ai/index.ts | 3 + 3 files changed, 100 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-generate-object.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/test-generate-object.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-generate-object.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-generate-object.mjs new file mode 100644 index 000000000000..64d0d3ba0ec7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-generate-object.mjs @@ -0,0 +1,30 @@ +import * as Sentry from '@sentry/node'; +import { generateObject } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import { z } from 'zod'; + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + // Test generateObject with schema + await generateObject({ + model: new MockLanguageModelV1({ + defaultObjectGenerationMode: 'json', + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 15, completionTokens: 25 }, + text: '{ "name": "John Doe", "age": 30 }', + }), + }), + schema: z.object({ + name: z.string().describe('The name of the person'), + age: z.number().describe('The age of the person'), + }), + schemaName: 'Person', + schemaDescription: 'A person with name and age', + prompt: 'Generate a person object', + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/test-generate-object.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/test-generate-object.ts new file mode 100644 index 000000000000..1e87b63535ac --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/test-generate-object.ts @@ -0,0 +1,67 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('Vercel AI integration - generateObject', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + const EXPECTED_TRANSACTION = { + transaction: 'main', + spans: expect.arrayContaining([ + // generateObject span + expect.objectContaining({ + data: expect.objectContaining({ + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateObject', + 'vercel.ai.pipeline.name': 'generateObject', + 'vercel.ai.streaming': false, + 'vercel.ai.settings.mode': 'json', + 'vercel.ai.settings.output': 'object', + 'gen_ai.request.schema': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'operation.name': 'ai.generateObject', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'generateObject', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // generateObject.doGenerate span + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': 'auto.vercelai.otel', + 'sentry.op': 'gen_ai.generate_object', + 'operation.name': 'ai.generateObject.doGenerate', + 'vercel.ai.operationId': 'ai.generateObject.doGenerate', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.pipeline.name': 'generateObject.doGenerate', + 'vercel.ai.streaming': false, + 'gen_ai.system': 'mock-provider', + 'gen_ai.request.model': 'mock-model-id', + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + }), + description: 'generate_object mock-model-id', + op: 'gen_ai.generate_object', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario-generate-object.mjs', 'instrument.mjs', (createRunner, test) => { + test('captures generateObject spans with schema attributes', async () => { + await createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + }); + }); +}); diff --git a/packages/core/src/utils/vercel-ai/index.ts b/packages/core/src/utils/vercel-ai/index.ts index 912dcaee3bc4..9b1cc2bc8aae 100644 --- a/packages/core/src/utils/vercel-ai/index.ts +++ b/packages/core/src/utils/vercel-ai/index.ts @@ -17,6 +17,7 @@ import { AI_RESPONSE_PROVIDER_METADATA_ATTRIBUTE, AI_RESPONSE_TEXT_ATTRIBUTE, AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, + AI_SCHEMA_ATTRIBUTE, AI_TELEMETRY_FUNCTION_ID_ATTRIBUTE, AI_TOOL_CALL_ARGS_ATTRIBUTE, AI_TOOL_CALL_ID_ATTRIBUTE, @@ -125,6 +126,8 @@ function processEndedVercelAiSpan(span: SpanJSON): void { renameAttributeKey(attributes, AI_TOOL_CALL_ARGS_ATTRIBUTE, 'gen_ai.tool.input'); renameAttributeKey(attributes, AI_TOOL_CALL_RESULT_ATTRIBUTE, 'gen_ai.tool.output'); + renameAttributeKey(attributes, AI_SCHEMA_ATTRIBUTE, 'gen_ai.request.schema'); + addProviderMetadataToAttributes(attributes); // Change attributes namespaced with `ai.X` to `vercel.ai.X` From a524022a87b3260bf1b4181d505c41d1d999f824 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Mon, 6 Oct 2025 11:22:35 +0200 Subject: [PATCH 13/15] chore: Add external contributor to CHANGELOG.md (#17866) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #17785 Co-authored-by: chargome <20254395+chargome@users.noreply.github.com> --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a1100ba7ad0..89da7076e902 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 @stefanvanderwolf. Thank you for your contribution! + ## 10.17.0 ### Important Changes From 9b584f26accc49cf77fd27884d2fa1a77373e36e Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 7 Oct 2025 11:56:05 +0200 Subject: [PATCH 14/15] fix(react-router): Fix type for `OriginalHandleRequest` with middleware (#17870) closes https://github.com/getsentry/sentry-javascript/issues/17749 --- .../src/server/createSentryHandleRequest.tsx | 27 +++++--- .../src/server/wrapSentryHandleRequest.ts | 65 +++++++++++++++++-- 2 files changed, 80 insertions(+), 12 deletions(-) diff --git a/packages/react-router/src/server/createSentryHandleRequest.tsx b/packages/react-router/src/server/createSentryHandleRequest.tsx index eaf13eb16779..75080b827165 100644 --- a/packages/react-router/src/server/createSentryHandleRequest.tsx +++ b/packages/react-router/src/server/createSentryHandleRequest.tsx @@ -53,6 +53,22 @@ export interface SentryHandleRequestOptions { botRegex?: RegExp; } +type HandleRequestWithoutMiddleware = ( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + routerContext: EntryContext, + loadContext: AppLoadContext, +) => Promise; + +type HandleRequestWithMiddleware = ( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + routerContext: EntryContext, + loadContext: RouterContextProvider, +) => Promise; + /** * A complete Sentry-instrumented handleRequest implementation that handles both * route parametrization and trace meta tag injection. @@ -62,13 +78,7 @@ export interface SentryHandleRequestOptions { */ export function createSentryHandleRequest( options: SentryHandleRequestOptions, -): ( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - routerContext: EntryContext, - loadContext: AppLoadContext | RouterContextProvider, -) => Promise { +): HandleRequestWithoutMiddleware & HandleRequestWithMiddleware { const { streamTimeout = 10000, renderToPipeableStream, @@ -135,5 +145,6 @@ export function createSentryHandleRequest( }; // Wrap the handle request function for request parametrization - return wrapSentryHandleRequest(handleRequest); + return wrapSentryHandleRequest(handleRequest as HandleRequestWithoutMiddleware) as HandleRequestWithoutMiddleware & + HandleRequestWithMiddleware; } diff --git a/packages/react-router/src/server/wrapSentryHandleRequest.ts b/packages/react-router/src/server/wrapSentryHandleRequest.ts index 5651ad208a9d..308b5c07a32b 100644 --- a/packages/react-router/src/server/wrapSentryHandleRequest.ts +++ b/packages/react-router/src/server/wrapSentryHandleRequest.ts @@ -10,12 +10,20 @@ import { } from '@sentry/core'; import type { AppLoadContext, EntryContext, RouterContextProvider } from 'react-router'; -type OriginalHandleRequest = ( +type OriginalHandleRequestWithoutMiddleware = ( request: Request, responseStatusCode: number, responseHeaders: Headers, routerContext: EntryContext, - loadContext: AppLoadContext | RouterContextProvider, + loadContext: AppLoadContext, +) => Promise; + +type OriginalHandleRequestWithMiddleware = ( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + routerContext: EntryContext, + loadContext: RouterContextProvider, ) => Promise; /** @@ -24,7 +32,27 @@ type OriginalHandleRequest = ( * @param originalHandle - The original handleRequest function to wrap * @returns A wrapped version of the handle request function with Sentry instrumentation */ -export function wrapSentryHandleRequest(originalHandle: OriginalHandleRequest): OriginalHandleRequest { +export function wrapSentryHandleRequest( + originalHandle: OriginalHandleRequestWithoutMiddleware, +): OriginalHandleRequestWithoutMiddleware; +/** + * Wraps the original handleRequest function to add Sentry instrumentation. + * + * @param originalHandle - The original handleRequest function to wrap + * @returns A wrapped version of the handle request function with Sentry instrumentation + */ +export function wrapSentryHandleRequest( + originalHandle: OriginalHandleRequestWithMiddleware, +): OriginalHandleRequestWithMiddleware; +/** + * Wraps the original handleRequest function to add Sentry instrumentation. + * + * @param originalHandle - The original handleRequest function to wrap + * @returns A wrapped version of the handle request function with Sentry instrumentation + */ +export function wrapSentryHandleRequest( + originalHandle: OriginalHandleRequestWithoutMiddleware | OriginalHandleRequestWithMiddleware, +): OriginalHandleRequestWithoutMiddleware | OriginalHandleRequestWithMiddleware { return async function sentryInstrumentedHandleRequest( request: Request, responseStatusCode: number, @@ -57,10 +85,39 @@ export function wrapSentryHandleRequest(originalHandle: OriginalHandleRequest): } try { - return await originalHandle(request, responseStatusCode, responseHeaders, routerContext, loadContext); + // Type guard to call the correct overload based on loadContext type + if (isRouterContextProvider(loadContext)) { + // loadContext is RouterContextProvider + return await (originalHandle as OriginalHandleRequestWithMiddleware)( + request, + responseStatusCode, + responseHeaders, + routerContext, + loadContext, + ); + } else { + // loadContext is AppLoadContext + return await (originalHandle as OriginalHandleRequestWithoutMiddleware)( + request, + responseStatusCode, + responseHeaders, + routerContext, + loadContext, + ); + } } finally { await flushIfServerless(); } + + /** + * Helper type guard to determine if the context is a RouterContextProvider. + * + * @param ctx - The context to check + * @returns True if the context is a RouterContextProvider + */ + function isRouterContextProvider(ctx: AppLoadContext | RouterContextProvider): ctx is RouterContextProvider { + return typeof (ctx as RouterContextProvider)?.get === 'function'; + } }; } From 9ee2045b0614813d966d6a2abed530b67b275c47 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Tue, 7 Oct 2025 12:08:42 +0200 Subject: [PATCH 15/15] meta(changelog): Update changelog for 10.18.0 --- CHANGELOG.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89da7076e902..1ae0eba65cec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,42 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.18.0 + +### Important Changes + +- **feat(node): `pino` integration ([#17584](https://github.com/getsentry/sentry-javascript/pull/17584))** + + This release adds a new `pino` integration for Node.js, enabling Sentry to capture logs from the Pino logging library. + +- **feat: Remove @sentry/pino-transport package ([#17851](https://github.com/getsentry/sentry-javascript/pull/17851))** + + The `@sentry/pino-transport` package has been removed. Please use the new `pino` integration in `@sentry/node` instead. + +- **feat(node-core): Extend onnhandledrejection with ignore errors option ([#17736](https://github.com/getsentry/sentry-javascript/pull/17736))** + + Added support for selectively suppressing specific errors with configurable logging control in onnhandledrejection integration. + +### Other Changes + +- feat(core): Rename vercelai.schema to gen_ai.request.schema ([#17850](https://github.com/getsentry/sentry-javascript/pull/17850)) +- feat(core): Support stream responses and tool calls for Google GenAI ([#17664](https://github.com/getsentry/sentry-javascript/pull/17664)) +- feat(nextjs): Attach headers using client hook ([#17831](https://github.com/getsentry/sentry-javascript/pull/17831)) +- fix(core): Keep all property values in baggage header ([#17847](https://github.com/getsentry/sentry-javascript/pull/17847)) +- fix(nestjs): Add support for Symbol as event name ([#17785](https://github.com/getsentry/sentry-javascript/pull/17785)) +- fix(nuxt): include `sentry.client.config.ts` in nuxt app types ([#17830](https://github.com/getsentry/sentry-javascript/pull/17830)) +- fix(react-router): Fix type for `OriginalHandleRequest` with middleware ([#17870](https://github.com/getsentry/sentry-javascript/pull/17870)) + +
+ Internal Changes + +- chore: Add external contributor to CHANGELOG.md ([#17866](https://github.com/getsentry/sentry-javascript/pull/17866)) +- chore(deps): Bump @sentry/cli from 2.53.0 to 2.56.0 ([#17819](https://github.com/getsentry/sentry-javascript/pull/17819)) +- chore(deps): Bump axios in browser integration tests ([#17839](https://github.com/getsentry/sentry-javascript/pull/17839)) +- chore(deps): Bump nestjs in integration tests ([#17840](https://github.com/getsentry/sentry-javascript/pull/17840)) + +
+ Work in this release was contributed by @stefanvanderwolf. Thank you for your contribution! ## 10.17.0