From b656b66ed24fd0b1870c05af8a32d34d5e1b1323 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 5 Mar 2025 12:37:25 +0100 Subject: [PATCH 01/14] fix(vue): Attach Pinia state only once per event (#15588) Each Pinia store will run through the event processor but the Pinia state should only be attached once. closes https://github.com/getsentry/sentry-javascript/issues/15587 --- packages/vue/src/pinia.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/vue/src/pinia.ts b/packages/vue/src/pinia.ts index 2448a30c4f08..c7448deaeed1 100644 --- a/packages/vue/src/pinia.ts +++ b/packages/vue/src/pinia.ts @@ -45,13 +45,20 @@ export const createSentryPiniaPlugin: (options?: SentryPiniaPluginOptions) => Pi const timestamp = new Date().toTimeString().split(' ')[0]; const filename = `pinia_state_all_stores_${timestamp}.json`; - hint.attachments = [ - ...(hint.attachments || []), - { - filename, - data: JSON.stringify(getAllStoreStates()), - }, - ]; + // event processor runs for each pinia store - attachment should only be added once per event + const hasExistingPiniaStateAttachment = hint.attachments?.some(attachment => + attachment.filename.startsWith('pinia_state_all_stores_'), + ); + + if (!hasExistingPiniaStateAttachment) { + hint.attachments = [ + ...(hint.attachments || []), + { + filename, + data: JSON.stringify(getAllStoreStates()), + }, + ]; + } } catch (_) { // empty } From fa6590b7ccc5d766a3c48c0bc2ca36b19e3e8241 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 5 Mar 2025 07:18:01 -0500 Subject: [PATCH 02/14] feat(replay): Bump rrweb to 2.34.0 (#15580) --- .../browser-integration-tests/package.json | 2 +- packages/replay-canvas/package.json | 2 +- packages/replay-internal/package.json | 4 +- yarn.lock | 42 +++++++++---------- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index f59ec9f94c42..396f48dbf87b 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -41,7 +41,7 @@ "dependencies": { "@babel/preset-typescript": "^7.16.7", "@playwright/test": "~1.50.0", - "@sentry-internal/rrweb": "2.33.0", + "@sentry-internal/rrweb": "2.34.0", "@sentry/browser": "9.3.0", "axios": "1.7.7", "babel-loader": "^8.2.2", diff --git a/packages/replay-canvas/package.json b/packages/replay-canvas/package.json index 39fae595cb92..5fb03d245af0 100644 --- a/packages/replay-canvas/package.json +++ b/packages/replay-canvas/package.json @@ -65,7 +65,7 @@ }, "homepage": "https://docs.sentry.io/platforms/javascript/session-replay/", "devDependencies": { - "@sentry-internal/rrweb": "2.33.0" + "@sentry-internal/rrweb": "2.34.0" }, "dependencies": { "@sentry-internal/replay": "9.3.0", diff --git a/packages/replay-internal/package.json b/packages/replay-internal/package.json index d8319d1847e8..b88afdd108aa 100644 --- a/packages/replay-internal/package.json +++ b/packages/replay-internal/package.json @@ -71,8 +71,8 @@ "devDependencies": { "@babel/core": "^7.17.5", "@sentry-internal/replay-worker": "9.3.0", - "@sentry-internal/rrweb": "2.33.0", - "@sentry-internal/rrweb-snapshot": "2.33.0", + "@sentry-internal/rrweb": "2.34.0", + "@sentry-internal/rrweb-snapshot": "2.34.0", "fflate": "0.8.2", "jest-matcher-utils": "^29.0.0", "jsdom-worker": "^0.2.1" diff --git a/yarn.lock b/yarn.lock index b384f605f637..b4f8c171dd4d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6959,34 +6959,34 @@ detect-libc "^2.0.2" node-abi "^3.61.0" -"@sentry-internal/rrdom@2.33.0": - version "2.33.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/rrdom/-/rrdom-2.33.0.tgz#16231b907e7d8a30d2c8a4de247bf87f272ded02" - integrity sha512-amTHYKq0u1FBC3lu6MM2Jnx04xIyDpsPcCJ8Pvi1sTyA/+mkDvGYXHWyjxVVWvhIOT6dVhuvWD82KW1gksrJcg== +"@sentry-internal/rrdom@2.34.0": + version "2.34.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/rrdom/-/rrdom-2.34.0.tgz#fccc9fe211c3995d4200abafbe8d75b671961ee9" + integrity sha512-NFGNzI9iGYpJ1D7j8qLu4pFMGDMumQzM9/wMPQpmDQTCZYV25To5lxT7z5K1huPAIyh5NLW+hQlMx/hXxXwJ6w== dependencies: - "@sentry-internal/rrweb-snapshot" "2.33.0" + "@sentry-internal/rrweb-snapshot" "2.34.0" -"@sentry-internal/rrweb-snapshot@2.33.0": - version "2.33.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/rrweb-snapshot/-/rrweb-snapshot-2.33.0.tgz#2b984757bd44e1d89022875f79ae16162cea78a6" - integrity sha512-sDg+i4QQ/nq97S4dyzMG0kbcQDbhHWFaoT8UXDODle9HpfeyoKuLF9d+JcHRZq0PID3/EIsWztTKw/xk96H8wQ== +"@sentry-internal/rrweb-snapshot@2.34.0": + version "2.34.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/rrweb-snapshot/-/rrweb-snapshot-2.34.0.tgz#79c2049b6c887e3c128d5fa80d6f745a61dd0e68" + integrity sha512-9Tb8jwVufn5GLV0d/CTuoZWo2O06ZB+xWeTJdEkbtJ6PAmO/Q7GQI3uNIx0pfFEnXP+0Km8CKKxpwkEM0z2m6w== -"@sentry-internal/rrweb-types@2.33.0": - version "2.33.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/rrweb-types/-/rrweb-types-2.33.0.tgz#10593725dac929a9e19b6071c516faff4c0a20d8" - integrity sha512-lxwBh+bqnKf2gOj2tX2fbgVk/njlNIQuM19FsRSqi+KIHY7F9RLEC/N7chtCMFbckex6kljKM4RIa4DfKLkHDA== +"@sentry-internal/rrweb-types@2.34.0": + version "2.34.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/rrweb-types/-/rrweb-types-2.34.0.tgz#32b853d93d1d9a1ae1888b17d84b24e674fadee0" + integrity sha512-6g5TN8YjqxrZOSQZGMLeZ2XYXdmqaKzPdIzKRySK+rKT/1fJE2gcefJEPDxiix0z/6/v5hGu/Ia8+wbJ7ACHwQ== dependencies: - "@sentry-internal/rrweb-snapshot" "2.33.0" + "@sentry-internal/rrweb-snapshot" "2.34.0" "@types/css-font-loading-module" "0.0.7" -"@sentry-internal/rrweb@2.33.0": - version "2.33.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/rrweb/-/rrweb-2.33.0.tgz#f1de2a7aca1a739acdfe91983b679c3bc3a448af" - integrity sha512-vjc+duxt3dxz6KVR6p+/kNl9AukMXmb7i6MMUZDn1TKfdeP9tGJprWl9fTkAmwkyQjm8VEWULs/3JK/u9dou5g== +"@sentry-internal/rrweb@2.34.0": + version "2.34.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/rrweb/-/rrweb-2.34.0.tgz#a32945504f1ba2ba60f2ebd7a17d2df5e1aa010d" + integrity sha512-NAzpnMOvsIV8o6rEvJ7SDs/TwuHXSrRmuAYYedrOQyJoLq00HF+6wQGe6SAyXv/bkumXmQfjyJ6bv4XNtC4S6g== dependencies: - "@sentry-internal/rrdom" "2.33.0" - "@sentry-internal/rrweb-snapshot" "2.33.0" - "@sentry-internal/rrweb-types" "2.33.0" + "@sentry-internal/rrdom" "2.34.0" + "@sentry-internal/rrweb-snapshot" "2.34.0" + "@sentry-internal/rrweb-types" "2.34.0" "@types/css-font-loading-module" "0.0.7" "@xstate/fsm" "^1.4.0" base64-arraybuffer "^1.0.1" From 502da24c5c55bc22b6066fba4ceefc8cb016bdf7 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Wed, 5 Mar 2025 13:51:13 +0100 Subject: [PATCH 03/14] deps(nextjs): Bump rollup to `4.34.9` (#15589) Fixes https://github.com/getsentry/sentry-javascript/issues/11949 --- packages/nextjs/package.json | 2 +- yarn.lock | 131 +++++++++++++++++++++++++++++++++-- 2 files changed, 128 insertions(+), 5 deletions(-) diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 72c616b597cf..560391996fd4 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -88,7 +88,7 @@ "@sentry/webpack-plugin": "3.2.1", "chalk": "3.0.0", "resolve": "1.22.8", - "rollup": "3.29.5", + "rollup": "4.34.9", "stacktrace-parser": "^0.1.10" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index b4f8c171dd4d..8251fd18b43d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6762,6 +6762,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.8.tgz#731df27dfdb77189547bcef96ada7bf166bbb2fb" integrity sha512-q217OSE8DTp8AFHuNHXo0Y86e1wtlfVrXiAlwkIvGRQv9zbc6mE3sjIVfwI8sYUyNxwOg0j/Vm1RKM04JcWLJw== +"@rollup/rollup-android-arm-eabi@4.34.9": + version "4.34.9" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.9.tgz#661a45a4709c70e59e596ec78daa9cb8b8d27604" + integrity sha512-qZdlImWXur0CFakn2BJ2znJOdqYZKiedEPEVNTBrpfPjc/YuTGcaYZcdmNFTkUj3DU0ZM/AElcM8Ybww3xVLzA== + "@rollup/rollup-android-arm64@4.30.1": version "4.30.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.30.1.tgz#9d81ea54fc5650eb4ebbc0a7d84cee331bfa30ad" @@ -6772,6 +6777,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.8.tgz#4bea6db78e1f6927405df7fe0faf2f5095e01343" integrity sha512-Gigjz7mNWaOL9wCggvoK3jEIUUbGul656opstjaUSGC3eT0BM7PofdAJaBfPFWWkXNVAXbaQtC99OCg4sJv70Q== +"@rollup/rollup-android-arm64@4.34.9": + version "4.34.9" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.9.tgz#128fe8dd510d880cf98b4cb6c7add326815a0c4b" + integrity sha512-4KW7P53h6HtJf5Y608T1ISKvNIYLWRKMvfnG0c44M6In4DQVU58HZFEVhWINDZKp7FZps98G3gxwC1sb0wXUUg== + "@rollup/rollup-darwin-arm64@4.30.1": version "4.30.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.30.1.tgz#29448cb1370cf678b50743d2e392be18470abc23" @@ -6782,6 +6792,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.8.tgz#a7aab77d44be3c44a20f946e10160f84e5450e7f" integrity sha512-02rVdZ5tgdUNRxIUrFdcMBZQoaPMrxtwSb+/hOfBdqkatYHR3lZ2A2EGyHq2sGOd0Owk80oV3snlDASC24He3Q== +"@rollup/rollup-darwin-arm64@4.34.9": + version "4.34.9" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.9.tgz#363467bc49fd0b1e17075798ac8e9ad1e1e29535" + integrity sha512-0CY3/K54slrzLDjOA7TOjN1NuLKERBgk9nY5V34mhmuu673YNb+7ghaDUs6N0ujXR7fz5XaS5Aa6d2TNxZd0OQ== + "@rollup/rollup-darwin-x64@4.30.1": version "4.30.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.30.1.tgz#0ca99741c3ed096700557a43bb03359450c7857d" @@ -6792,6 +6807,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.8.tgz#c572c024b57ee8ddd1b0851703ace9eb6cc0dd82" integrity sha512-qIP/elwR/tq/dYRx3lgwK31jkZvMiD6qUtOycLhTzCvrjbZ3LjQnEM9rNhSGpbLXVJYQ3rq39A6Re0h9tU2ynw== +"@rollup/rollup-darwin-x64@4.34.9": + version "4.34.9" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.9.tgz#c2fe3d85fffe47f0ed0f076b3563ada22c8af19c" + integrity sha512-eOojSEAi/acnsJVYRxnMkPFqcxSMFfrw7r2iD9Q32SGkb/Q9FpUY1UlAu1DH9T7j++gZ0lHjnm4OyH2vCI7l7Q== + "@rollup/rollup-freebsd-arm64@4.30.1": version "4.30.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.30.1.tgz#233f8e4c2f54ad9b719cd9645887dcbd12b38003" @@ -6802,6 +6822,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.8.tgz#cf74f8113b5a83098a5c026c165742277cbfb88b" integrity sha512-IQNVXL9iY6NniYbTaOKdrlVP3XIqazBgJOVkddzJlqnCpRi/yAeSOa8PLcECFSQochzqApIOE1GHNu3pCz+BDA== +"@rollup/rollup-freebsd-arm64@4.34.9": + version "4.34.9" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.9.tgz#d95bd8f6eaaf829781144fc8bd2d5d71d9f6a9f5" + integrity sha512-2lzjQPJbN5UnHm7bHIUKFMulGTQwdvOkouJDpPysJS+QFBGDJqcfh+CxxtG23Ik/9tEvnebQiylYoazFMAgrYw== + "@rollup/rollup-freebsd-x64@4.30.1": version "4.30.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.30.1.tgz#dfba762a023063dc901610722995286df4a48360" @@ -6812,6 +6837,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.8.tgz#39561f3a2f201a4ad6a01425b1ff5928154ecd7c" integrity sha512-TYXcHghgnCqYFiE3FT5QwXtOZqDj5GmaFNTNt3jNC+vh22dc/ukG2cG+pi75QO4kACohZzidsq7yKTKwq/Jq7Q== +"@rollup/rollup-freebsd-x64@4.34.9": + version "4.34.9" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.9.tgz#c3576c6011656e4966ded29f051edec636b44564" + integrity sha512-SLl0hi2Ah2H7xQYd6Qaiu01kFPzQ+hqvdYSoOtHYg/zCIFs6t8sV95kaoqjzjFwuYQLtOI0RZre/Ke0nPaQV+g== + "@rollup/rollup-linux-arm-gnueabihf@4.30.1": version "4.30.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.30.1.tgz#b9da54171726266c5ef4237f462a85b3c3cf6ac9" @@ -6822,6 +6852,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.8.tgz#980d6061e373bfdaeb67925c46d2f8f9b3de537f" integrity sha512-A4iphFGNkWRd+5m3VIGuqHnG3MVnqKe7Al57u9mwgbyZ2/xF9Jio72MaY7xxh+Y87VAHmGQr73qoKL9HPbXj1g== +"@rollup/rollup-linux-arm-gnueabihf@4.34.9": + version "4.34.9" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.9.tgz#48c87d0dee4f8dc9591a416717f91b4a89d77e3d" + integrity sha512-88I+D3TeKItrw+Y/2ud4Tw0+3CxQ2kLgu3QvrogZ0OfkmX/DEppehus7L3TS2Q4lpB+hYyxhkQiYPJ6Mf5/dPg== + "@rollup/rollup-linux-arm-musleabihf@4.30.1": version "4.30.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.30.1.tgz#b9db69b3f85f5529eb992936d8f411ee6d04297b" @@ -6832,6 +6867,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.8.tgz#f91a90f30dc00d5a64ac2d9bbedc829cd3cfaa78" integrity sha512-S0lqKLfTm5u+QTxlFiAnb2J/2dgQqRy/XvziPtDd1rKZFXHTyYLoVL58M/XFwDI01AQCDIevGLbQrMAtdyanpA== +"@rollup/rollup-linux-arm-musleabihf@4.34.9": + version "4.34.9" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.9.tgz#f4c4e7c03a7767f2e5aa9d0c5cfbf5c0f59f2d41" + integrity sha512-3qyfWljSFHi9zH0KgtEPG4cBXHDFhwD8kwg6xLfHQ0IWuH9crp005GfoUUh/6w9/FWGBwEHg3lxK1iHRN1MFlA== + "@rollup/rollup-linux-arm64-gnu@4.30.1": version "4.30.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.30.1.tgz#2550cf9bb4d47d917fd1ab4af756d7bbc3ee1528" @@ -6842,6 +6882,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.8.tgz#fac700fa5c38bc13a0d5d34463133093da4c92a0" integrity sha512-jpz9YOuPiSkL4G4pqKrus0pn9aYwpImGkosRKwNi+sJSkz+WU3anZe6hi73StLOQdfXYXC7hUfsQlTnjMd3s1A== +"@rollup/rollup-linux-arm64-gnu@4.34.9": + version "4.34.9" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.9.tgz#1015c9d07a99005025d13b8622b7600029d0b52f" + integrity sha512-6TZjPHjKZUQKmVKMUowF3ewHxctrRR09eYyvT5eFv8w/fXarEra83A2mHTVJLA5xU91aCNOUnM+DWFMSbQ0Nxw== + "@rollup/rollup-linux-arm64-musl@4.30.1": version "4.30.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.30.1.tgz#9d06b26d286c7dded6336961a2f83e48330e0c80" @@ -6852,6 +6897,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.8.tgz#f50ecccf8c78841ff6df1706bc4782d7f62bf9c3" integrity sha512-KdSfaROOUJXgTVxJNAZ3KwkRc5nggDk+06P6lgi1HLv1hskgvxHUKZ4xtwHkVYJ1Rep4GNo+uEfycCRRxht7+Q== +"@rollup/rollup-linux-arm64-musl@4.34.9": + version "4.34.9" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.9.tgz#8f895eb5577748fc75af21beae32439626e0a14c" + integrity sha512-LD2fytxZJZ6xzOKnMbIpgzFOuIKlxVOpiMAXawsAZ2mHBPEYOnLRK5TTEsID6z4eM23DuO88X0Tq1mErHMVq0A== + "@rollup/rollup-linux-loongarch64-gnu@4.30.1": version "4.30.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.30.1.tgz#e957bb8fee0c8021329a34ca8dfa825826ee0e2e" @@ -6862,6 +6912,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.8.tgz#5869dc0b28242da6553e2b52af41374f4038cd6e" integrity sha512-NyF4gcxwkMFRjgXBM6g2lkT58OWztZvw5KkV2K0qqSnUEqCVcqdh2jN4gQrTn/YUpAcNKyFHfoOZEer9nwo6uQ== +"@rollup/rollup-linux-loongarch64-gnu@4.34.9": + version "4.34.9" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.9.tgz#c9cd5dbbdc6b3ca4dbeeb0337498cf31949004a0" + integrity sha512-dRAgTfDsn0TE0HI6cmo13hemKpVHOEyeciGtvlBTkpx/F65kTvShtY/EVyZEIfxFkV5JJTuQ9tP5HGBS0hfxIg== + "@rollup/rollup-linux-powerpc64le-gnu@4.30.1": version "4.30.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.30.1.tgz#e8585075ddfb389222c5aada39ea62d6d2511ccc" @@ -6872,6 +6927,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.8.tgz#5cdd9f851ce1bea33d6844a69f9574de335f20b1" integrity sha512-LMJc999GkhGvktHU85zNTDImZVUCJ1z/MbAJTnviiWmmjyckP5aQsHtcujMjpNdMZPT2rQEDBlJfubhs3jsMfw== +"@rollup/rollup-linux-powerpc64le-gnu@4.34.9": + version "4.34.9" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.9.tgz#7ebb5b4441faa17843a210f7d0583a20c93b40e4" + integrity sha512-PHcNOAEhkoMSQtMf+rJofwisZqaU8iQ8EaSps58f5HYll9EAY5BSErCZ8qBDMVbq88h4UxaNPlbrKqfWP8RfJA== + "@rollup/rollup-linux-riscv64-gnu@4.30.1": version "4.30.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.30.1.tgz#7d0d40cee7946ccaa5a4e19a35c6925444696a9e" @@ -6882,6 +6942,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.8.tgz#ef5dc37f4388f5253f0def43e1440ec012af204d" integrity sha512-xAQCAHPj8nJq1PI3z8CIZzXuXCstquz7cIOL73HHdXiRcKk8Ywwqtx2wrIy23EcTn4aZ2fLJNBB8d0tQENPCmw== +"@rollup/rollup-linux-riscv64-gnu@4.34.9": + version "4.34.9" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.9.tgz#10f5d7349fbd2fe78f9e36ecc90aab3154435c8d" + integrity sha512-Z2i0Uy5G96KBYKjeQFKbbsB54xFOL5/y1P5wNBsbXB8yE+At3oh0DVMjQVzCJRJSfReiB2tX8T6HUFZ2k8iaKg== + "@rollup/rollup-linux-s390x-gnu@4.30.1": version "4.30.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.30.1.tgz#c2dcd8a4b08b2f2778eceb7a5a5dfde6240ebdea" @@ -6892,6 +6957,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.8.tgz#7dbc3ccbcbcfb3e65be74538dfb6e8dd16178fde" integrity sha512-DdePVk1NDEuc3fOe3dPPTb+rjMtuFw89gw6gVWxQFAuEqqSdDKnrwzZHrUYdac7A7dXl9Q2Vflxpme15gUWQFA== +"@rollup/rollup-linux-s390x-gnu@4.34.9": + version "4.34.9" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.9.tgz#196347d2fa20593ab09d0b7e2589fb69bdf742c6" + integrity sha512-U+5SwTMoeYXoDzJX5dhDTxRltSrIax8KWwfaaYcynuJw8mT33W7oOgz0a+AaXtGuvhzTr2tVKh5UO8GVANTxyQ== + "@rollup/rollup-linux-x64-gnu@4.30.1": version "4.30.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.30.1.tgz#183637d91456877cb83d0a0315eb4788573aa588" @@ -6902,6 +6972,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.8.tgz#5783fc0adcab7dc069692056e8ca8d83709855ce" integrity sha512-8y7ED8gjxITUltTUEJLQdgpbPh1sUQ0kMTmufRF/Ns5tI9TNMNlhWtmPKKHCU0SilX+3MJkZ0zERYYGIVBYHIA== +"@rollup/rollup-linux-x64-gnu@4.34.9": + version "4.34.9" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.9.tgz#7193cbd8d128212b8acda37e01b39d9e96259ef8" + integrity sha512-FwBHNSOjUTQLP4MG7y6rR6qbGw4MFeQnIBrMe161QGaQoBQLqSUEKlHIiVgF3g/mb3lxlxzJOpIBhaP+C+KP2A== + "@rollup/rollup-linux-x64-musl@4.30.1": version "4.30.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.30.1.tgz#036a4c860662519f1f9453807547fd2a11d5bb01" @@ -6912,6 +6987,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.8.tgz#00b6c29b298197a384e3c659910b47943003a678" integrity sha512-SCXcP0ZpGFIe7Ge+McxY5zKxiEI5ra+GT3QRxL0pMMtxPfpyLAKleZODi1zdRHkz5/BhueUrYtYVgubqe9JBNQ== +"@rollup/rollup-linux-x64-musl@4.34.9": + version "4.34.9" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.9.tgz#29a6867278ca0420b891574cfab98ecad70c59d1" + integrity sha512-cYRpV4650z2I3/s6+5/LONkjIz8MBeqrk+vPXV10ORBnshpn8S32bPqQ2Utv39jCiDcO2eJTuSlPXpnvmaIgRA== + "@rollup/rollup-win32-arm64-msvc@4.30.1": version "4.30.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.30.1.tgz#51cad812456e616bfe4db5238fb9c7497e042a52" @@ -6922,6 +7002,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.8.tgz#cbfee01f1fe73791c35191a05397838520ca3cdd" integrity sha512-YHYsgzZgFJzTRbth4h7Or0m5O74Yda+hLin0irAIobkLQFRQd1qWmnoVfwmKm9TXIZVAD0nZ+GEb2ICicLyCnQ== +"@rollup/rollup-win32-arm64-msvc@4.34.9": + version "4.34.9" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.9.tgz#89427dcac0c8e3a6d32b13a03a296a275d0de9a9" + integrity sha512-z4mQK9dAN6byRA/vsSgQiPeuO63wdiDxZ9yg9iyX2QTzKuQM7T4xlBoeUP/J8uiFkqxkcWndWi+W7bXdPbt27Q== + "@rollup/rollup-win32-ia32-msvc@4.30.1": version "4.30.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.30.1.tgz#661c8b3e4cd60f51deaa39d153aac4566e748e5e" @@ -6932,6 +7017,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.8.tgz#95cdbdff48fe6c948abcf6a1d500b2bd5ce33f62" integrity sha512-r3NRQrXkHr4uWy5TOjTpTYojR9XmF0j/RYgKCef+Ag46FWUTltm5ziticv8LdNsDMehjJ543x/+TJAek/xBA2w== +"@rollup/rollup-win32-ia32-msvc@4.34.9": + version "4.34.9" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.9.tgz#ecb9711ba2b6d2bf6ee51265abe057ab90913deb" + integrity sha512-KB48mPtaoHy1AwDNkAJfHXvHp24H0ryZog28spEs0V48l3H1fr4i37tiyHsgKZJnCmvxsbATdZGBpbmxTE3a9w== + "@rollup/rollup-win32-x64-msvc@4.30.1": version "4.30.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.30.1.tgz#73bf1885ff052b82fbb0f82f8671f73c36e9137c" @@ -6942,6 +7032,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.8.tgz#4cdb2cfae69cdb7b1a3cc58778e820408075e928" integrity sha512-U0FaE5O1BCpZSeE6gBl3c5ObhePQSfk9vDRToMmTkbhCOgW4jqvtS5LGyQ76L1fH8sM0keRp4uDTsbjiUyjk0g== +"@rollup/rollup-win32-x64-msvc@4.34.9": + version "4.34.9" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.9.tgz#1973871850856ae72bc678aeb066ab952330e923" + integrity sha512-AyleYRPU7+rgkMWbEh71fQlrzRfeP6SyMnRf9XX4fCdDPAJumdSBqYEcWPMzVQ4ScAl7E4oFfK0GUVn77xSwbw== + "@schematics/angular@14.2.13": version "14.2.13" resolved "https://registry.yarnpkg.com/@schematics/angular/-/angular-14.2.13.tgz#35ee9120a3ac07077bad169fa74fdf4ce4e193d7" @@ -26881,11 +26976,32 @@ rollup-pluginutils@^2.8.1, rollup-pluginutils@^2.8.2: dependencies: estree-walker "^0.6.1" -rollup@3.29.5, rollup@^3.27.1, rollup@^3.28.1: - version "3.29.5" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.29.5.tgz#8a2e477a758b520fb78daf04bca4c522c1da8a54" - integrity sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w== +rollup@4.34.9: + version "4.34.9" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.34.9.tgz#e1eb397856476778aeb6ac2ac3d09b2ce177a558" + integrity sha512-nF5XYqWWp9hx/LrpC8sZvvvmq0TeTjQgaZHYmAgwysT9nh8sWnZhBnM8ZyVbbJFIQBLwHDNoMqsBZBbUo4U8sQ== + dependencies: + "@types/estree" "1.0.6" optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.34.9" + "@rollup/rollup-android-arm64" "4.34.9" + "@rollup/rollup-darwin-arm64" "4.34.9" + "@rollup/rollup-darwin-x64" "4.34.9" + "@rollup/rollup-freebsd-arm64" "4.34.9" + "@rollup/rollup-freebsd-x64" "4.34.9" + "@rollup/rollup-linux-arm-gnueabihf" "4.34.9" + "@rollup/rollup-linux-arm-musleabihf" "4.34.9" + "@rollup/rollup-linux-arm64-gnu" "4.34.9" + "@rollup/rollup-linux-arm64-musl" "4.34.9" + "@rollup/rollup-linux-loongarch64-gnu" "4.34.9" + "@rollup/rollup-linux-powerpc64le-gnu" "4.34.9" + "@rollup/rollup-linux-riscv64-gnu" "4.34.9" + "@rollup/rollup-linux-s390x-gnu" "4.34.9" + "@rollup/rollup-linux-x64-gnu" "4.34.9" + "@rollup/rollup-linux-x64-musl" "4.34.9" + "@rollup/rollup-win32-arm64-msvc" "4.34.9" + "@rollup/rollup-win32-ia32-msvc" "4.34.9" + "@rollup/rollup-win32-x64-msvc" "4.34.9" fsevents "~2.3.2" rollup@^2.70.0: @@ -26895,6 +27011,13 @@ rollup@^2.70.0: optionalDependencies: fsevents "~2.3.2" +rollup@^3.27.1, rollup@^3.28.1: + version "3.29.5" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.29.5.tgz#8a2e477a758b520fb78daf04bca4c522c1da8a54" + integrity sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w== + optionalDependencies: + fsevents "~2.3.2" + rollup@^4.18.0, rollup@^4.20.0, rollup@^4.24.2: version "4.30.1" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.30.1.tgz#d5c3d066055259366cdc3eb6f1d051c5d6afaf74" From 97155e966bea86af37714ddc0b4e48d6358b38cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Emanuel=20Surdi?= Date: Wed, 5 Mar 2025 15:47:00 +0100 Subject: [PATCH 04/14] fix(browser): Call original function on early return from patched history API (#15576) In https://github.com/getsentry/sentry-javascript/pull/14696 an early `return` was introduced that alters the default behavior of the underlying history API that is being patched. Instead of just returning, the original/underlying function should be called to keep the default behavior. --- packages/browser-utils/src/instrument/history.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/browser-utils/src/instrument/history.ts b/packages/browser-utils/src/instrument/history.ts index ad5ecb75f2ed..8494fca3076e 100644 --- a/packages/browser-utils/src/instrument/history.ts +++ b/packages/browser-utils/src/instrument/history.ts @@ -51,7 +51,7 @@ function instrumentHistory(): void { lastHref = to; if (from === to) { - return; + return originalHistoryFunction.apply(this, args); } const handlerData = { from, to } satisfies HandlerDataHistory; From 5bf45985c6ddc9b48b559e5a8376360cc87cf818 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 5 Mar 2025 16:36:56 +0100 Subject: [PATCH 05/14] feat(bun): Automatically add performance integrations (#15586) Automatically adds performance integrations to bun when tracing is enabled. --- packages/bun/src/sdk.ts | 3 + packages/bun/test/init.test.ts | 112 +++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 packages/bun/test/init.test.ts diff --git a/packages/bun/src/sdk.ts b/packages/bun/src/sdk.ts index 6c56a66aecea..734f331c25d8 100644 --- a/packages/bun/src/sdk.ts +++ b/packages/bun/src/sdk.ts @@ -2,6 +2,7 @@ import * as os from 'node:os'; import { applySdkMetadata, functionToStringIntegration, + hasSpansEnabled, inboundFiltersIntegration, linkedErrorsIntegration, requestDataIntegration, @@ -11,6 +12,7 @@ import type { NodeClient } from '@sentry/node'; import { consoleIntegration, contextLinesIntegration, + getAutoPerformanceIntegrations, httpIntegration, init as initNode, modulesIntegration, @@ -48,6 +50,7 @@ export function getDefaultIntegrations(_options: Options): Integration[] { modulesIntegration(), // Bun Specific bunServerIntegration(), + ...(hasSpansEnabled(_options) ? getAutoPerformanceIntegrations() : []), ]; } diff --git a/packages/bun/test/init.test.ts b/packages/bun/test/init.test.ts new file mode 100644 index 000000000000..45b146cf8ef4 --- /dev/null +++ b/packages/bun/test/init.test.ts @@ -0,0 +1,112 @@ +import { type Integration } from '@sentry/core'; +import * as sentryNode from '@sentry/node'; +import type { Mock } from 'bun:test'; +import { afterEach, beforeEach, describe, it, spyOn, mock, expect } from 'bun:test'; +import { getClient, init } from '../src'; + +const PUBLIC_DSN = 'https://username@domain/123'; + +class MockIntegration implements Integration { + public name: string; + public setupOnce: Mock<() => void>; + public constructor(name: string) { + this.name = name; + this.setupOnce = mock(() => undefined); + } +} + +describe('init()', () => { + let mockAutoPerformanceIntegrations: Mock<() => Integration[]>; + + beforeEach(() => { + // @ts-expect-error weird + mockAutoPerformanceIntegrations = spyOn(sentryNode, 'getAutoPerformanceIntegrations'); + }); + + afterEach(() => { + mockAutoPerformanceIntegrations.mockRestore(); + }); + + describe('integrations', () => { + it("doesn't install default integrations if told not to", () => { + init({ dsn: PUBLIC_DSN, defaultIntegrations: false }); + + const client = getClient(); + + expect(client?.getOptions().integrations).toEqual([]); + + expect(mockAutoPerformanceIntegrations).toHaveBeenCalledTimes(0); + }); + + it('installs merged default integrations, with overrides provided through options', () => { + const mockDefaultIntegrations = [ + new MockIntegration('Some mock integration 2.1'), + new MockIntegration('Some mock integration 2.2'), + ]; + + const mockIntegrations = [ + new MockIntegration('Some mock integration 2.1'), + new MockIntegration('Some mock integration 2.3'), + ]; + + init({ dsn: PUBLIC_DSN, integrations: mockIntegrations, defaultIntegrations: mockDefaultIntegrations }); + + expect(mockDefaultIntegrations[0]?.setupOnce).toHaveBeenCalledTimes(0); + expect(mockDefaultIntegrations[1]?.setupOnce).toHaveBeenCalledTimes(1); + expect(mockIntegrations[0]?.setupOnce).toHaveBeenCalledTimes(1); + expect(mockIntegrations[1]?.setupOnce).toHaveBeenCalledTimes(1); + expect(mockAutoPerformanceIntegrations).toHaveBeenCalledTimes(0); + }); + + it('installs integrations returned from a callback function', () => { + const mockDefaultIntegrations = [ + new MockIntegration('Some mock integration 3.1'), + new MockIntegration('Some mock integration 3.2'), + ]; + + const newIntegration = new MockIntegration('Some mock integration 3.3'); + + init({ + dsn: PUBLIC_DSN, + defaultIntegrations: mockDefaultIntegrations, + integrations: integrations => { + const newIntegrations = [...integrations]; + newIntegrations[1] = newIntegration; + return newIntegrations; + }, + }); + + expect(mockDefaultIntegrations[0]?.setupOnce).toHaveBeenCalledTimes(1); + expect(mockDefaultIntegrations[1]?.setupOnce).toHaveBeenCalledTimes(0); + expect(newIntegration.setupOnce).toHaveBeenCalledTimes(1); + expect(mockAutoPerformanceIntegrations).toHaveBeenCalledTimes(0); + }); + + it('installs performance default instrumentations if tracing is enabled', () => { + const autoPerformanceIntegrations = [new MockIntegration('Performance integration')]; + mockAutoPerformanceIntegrations.mockImplementation(() => autoPerformanceIntegrations); + + const mockIntegrations = [ + new MockIntegration('Some mock integration 4.1'), + new MockIntegration('Some mock integration 4.3'), + ]; + + init({ + dsn: PUBLIC_DSN, + integrations: mockIntegrations, + tracesSampleRate: 1, + }); + + expect(mockIntegrations[0]?.setupOnce).toHaveBeenCalledTimes(1); + expect(mockIntegrations[1]?.setupOnce).toHaveBeenCalledTimes(1); + expect(autoPerformanceIntegrations[0]?.setupOnce).toHaveBeenCalledTimes(1); + expect(mockAutoPerformanceIntegrations).toHaveBeenCalledTimes(1); + + const integrations = getClient()?.getOptions().integrations; + expect(integrations).toBeArray(); + expect(integrations?.map(({ name }) => name)).toContain('Performance integration'); + expect(integrations?.map(({ name }) => name)).toContain('Some mock integration 4.1'); + expect(integrations?.map(({ name }) => name)).toContain('Some mock integration 4.3'); + }); + }); +}); From 9db024d74d89541c952bca282b18152f7a4a6615 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 5 Mar 2025 17:32:33 +0100 Subject: [PATCH 06/14] fix(nestjs): Copy metadata in custom decorators (#15598) - adds functionality for copying metadata in our custom decorators - applies this to all our decorators, not just `SentryTraced` - unit tests closes https://github.com/getsentry/sentry-javascript/issues/14384 --- packages/nestjs/package.json | 3 +- packages/nestjs/src/decorators.ts | 47 ++- packages/nestjs/test/decorators.test.ts | 376 ++++++++++++++++++++++++ yarn.lock | 5 + 4 files changed, 422 insertions(+), 9 deletions(-) create mode 100644 packages/nestjs/test/decorators.test.ts diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index 2d71c0b082db..56ba9edd37ba 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -54,7 +54,8 @@ }, "devDependencies": { "@nestjs/common": "^10.0.0", - "@nestjs/core": "^10.0.0" + "@nestjs/core": "^10.0.0", + "reflect-metadata": "^0.2.2" }, "peerDependencies": { "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", diff --git a/packages/nestjs/src/decorators.ts b/packages/nestjs/src/decorators.ts index 42592e62d553..69d54377332d 100644 --- a/packages/nestjs/src/decorators.ts +++ b/packages/nestjs/src/decorators.ts @@ -20,6 +20,9 @@ export const SentryCron = (monitorSlug: string, monitorConfig?: MonitorConfig): monitorConfig, ); }; + + copyFunctionNameAndMetadata({ originalMethod, descriptor }); + return descriptor; }; }; @@ -28,7 +31,7 @@ export const SentryCron = (monitorSlug: string, monitorConfig?: MonitorConfig): * A decorator usable to wrap arbitrary functions with spans. */ export function SentryTraced(op: string = 'function') { - return function (target: unknown, propertyKey: string, descriptor: PropertyDescriptor) { + return function (_target: unknown, propertyKey: string, descriptor: PropertyDescriptor) { const originalMethod = descriptor.value as (...args: unknown[]) => Promise | unknown; // function can be sync or async descriptor.value = function (...args: unknown[]) { @@ -43,13 +46,7 @@ export function SentryTraced(op: string = 'function') { ); }; - // preserve the original name on the decorated function - Object.defineProperty(descriptor.value, 'name', { - value: originalMethod.name, - configurable: true, - enumerable: true, - writable: true, - }); + copyFunctionNameAndMetadata({ originalMethod, descriptor }); return descriptor; }; @@ -71,6 +68,40 @@ export function SentryExceptionCaptured() { return originalCatch.apply(this, [exception, host, ...args]); }; + copyFunctionNameAndMetadata({ originalMethod: originalCatch, descriptor }); + return descriptor; }; } + +/** + * Copies the function name and metadata from the original method to the decorated method. + * This ensures that the decorated method maintains the same name and metadata as the original. + * + * @param {Function} params.originalMethod - The original method being decorated + * @param {PropertyDescriptor} params.descriptor - The property descriptor containing the decorated method + */ +function copyFunctionNameAndMetadata({ + originalMethod, + descriptor, +}: { + descriptor: PropertyDescriptor; + originalMethod: (...args: unknown[]) => unknown; +}): void { + // preserve the original name on the decorated function + Object.defineProperty(descriptor.value, 'name', { + value: originalMethod.name, + configurable: true, + enumerable: true, + writable: true, + }); + + // copy metadata + if (typeof Reflect !== 'undefined' && typeof Reflect.getMetadataKeys === 'function') { + const originalMetaData = Reflect.getMetadataKeys(originalMethod); + for (const key of originalMetaData) { + const value = Reflect.getMetadata(key, originalMethod); + Reflect.defineMetadata(key, value, descriptor.value); + } + } +} diff --git a/packages/nestjs/test/decorators.test.ts b/packages/nestjs/test/decorators.test.ts new file mode 100644 index 000000000000..e3e99b700273 --- /dev/null +++ b/packages/nestjs/test/decorators.test.ts @@ -0,0 +1,376 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SentryCron, SentryExceptionCaptured, SentryTraced } from '../src/decorators'; +import * as core from '@sentry/core'; +import * as helpers from '../src/helpers'; +import 'reflect-metadata'; + +describe('SentryTraced decorator', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should create a span with correct operation and name', async () => { + const startSpanSpy = vi.spyOn(core, 'startSpan'); + + const originalMethod = async (param1: string, param2: number): Promise => { + return `${param1}-${param2}`; + }; + + const descriptor: PropertyDescriptor = { + value: originalMethod, + writable: true, + enumerable: true, + configurable: true, + }; + + const decoratedDescriptor = SentryTraced('test-operation')( + {}, // target + 'testMethod', + descriptor, + ); + + const decoratedMethod = decoratedDescriptor.value as typeof originalMethod; + const result = await decoratedMethod('test', 123); + + expect(result).toBe('test-123'); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenCalledWith( + { + op: 'test-operation', + name: 'testMethod', + }, + expect.any(Function), + ); + }); + + it('should use default operation name when not provided', async () => { + const startSpanSpy = vi.spyOn(core, 'startSpan'); + + const originalMethod = async (): Promise => { + return 'success'; + }; + + const descriptor: PropertyDescriptor = { + value: originalMethod, + writable: true, + enumerable: true, + configurable: true, + }; + + const decoratedDescriptor = SentryTraced()({}, 'testDefaultOp', descriptor); + const decoratedMethod = decoratedDescriptor.value as typeof originalMethod; + const result = await decoratedMethod(); + + expect(result).toBe('success'); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenCalledWith( + { + op: 'function', // default value + name: 'testDefaultOp', + }, + expect.any(Function), + ); + }); + + it('should work with synchronous methods', () => { + const startSpanSpy = vi.spyOn(core, 'startSpan'); + + const originalMethod = (value: number): number => { + return value * 2; + }; + + const descriptor: PropertyDescriptor = { + value: originalMethod, + writable: true, + enumerable: true, + configurable: true, + }; + + const decoratedDescriptor = SentryTraced('sync-operation')({}, 'syncMethod', descriptor); + const decoratedMethod = decoratedDescriptor.value as typeof originalMethod; + const result = decoratedMethod(5); + + expect(result).toBe(10); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenCalledWith( + { + op: 'sync-operation', + name: 'syncMethod', + }, + expect.any(Function), + ); + }); + + it('should handle complex object parameters', () => { + const startSpanSpy = vi.spyOn(core, 'startSpan'); + + const originalMethod = (data: { id: number; items: string[] }): string[] => { + return data.items.map(item => `${data.id}-${item}`); + }; + + const descriptor: PropertyDescriptor = { + value: originalMethod, + writable: true, + enumerable: true, + configurable: true, + }; + + const decoratedDescriptor = SentryTraced('data-processing')({}, 'processData', descriptor); + const decoratedMethod = decoratedDescriptor.value as typeof originalMethod; + const complexData = { id: 123, items: ['a', 'b', 'c'] }; + const result = decoratedMethod(complexData); + + expect(result).toEqual(['123-a', '123-b', '123-c']); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + }); + + it('should preserve function metadata', () => { + const getMetadataKeysSpy = vi.spyOn(Reflect, 'getMetadataKeys').mockReturnValue(['test-key']); + const getMetadataSpy = vi.spyOn(Reflect, 'getMetadata').mockReturnValue('test-value'); + const defineMetadataSpy = vi.spyOn(Reflect, 'defineMetadata').mockImplementation(() => {}); + + const originalMethod = () => 'result'; + const descriptor = { + value: originalMethod, + writable: true, + configurable: true, + enumerable: true, + }; + + const decoratedDescriptor = SentryTraced()({}, 'metadataMethod', descriptor); + decoratedDescriptor.value(); + + expect(getMetadataKeysSpy).toHaveBeenCalled(); + expect(getMetadataSpy).toHaveBeenCalled(); + expect(defineMetadataSpy).toHaveBeenCalled(); + + getMetadataKeysSpy.mockRestore(); + getMetadataSpy.mockRestore(); + defineMetadataSpy.mockRestore(); + }); +}); + +describe('SentryCron decorator', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call withMonitor with correct parameters', async () => { + const withMonitorSpy = vi.spyOn(core, 'withMonitor'); + + const originalMethod = async (): Promise => { + return 'success'; + }; + + const descriptor: PropertyDescriptor = { + value: originalMethod, + writable: true, + enumerable: true, + configurable: true, + }; + + const monitorSlug = 'test-monitor'; + const monitorConfig: core.MonitorConfig = { schedule: { value: '10 * * * *', type: 'crontab' } }; + + const decoratedDescriptor = SentryCron(monitorSlug, monitorConfig)( + {}, // target + 'cronMethod', + descriptor, + ); + + const decoratedMethod = decoratedDescriptor?.value as typeof originalMethod; + const result = await decoratedMethod(); + + expect(result).toBe('success'); + expect(withMonitorSpy).toHaveBeenCalledTimes(1); + expect(withMonitorSpy).toHaveBeenCalledWith(monitorSlug, expect.any(Function), monitorConfig); + }); + + it('should work with optional monitor config', async () => { + const withMonitorSpy = vi.spyOn(core, 'withMonitor'); + + const originalMethod = async (): Promise => { + return 'success'; + }; + + const descriptor: PropertyDescriptor = { + value: originalMethod, + writable: true, + enumerable: true, + configurable: true, + }; + + const monitorSlug = 'test-monitor'; + + const decoratedDescriptor = SentryCron(monitorSlug)( + {}, // target + 'cronMethod', + descriptor, + ); + + const decoratedMethod = decoratedDescriptor?.value as typeof originalMethod; + const result = await decoratedMethod(); + + expect(result).toBe('success'); + expect(withMonitorSpy).toHaveBeenCalledTimes(1); + expect(withMonitorSpy).toHaveBeenCalledWith(monitorSlug, expect.any(Function), undefined); + }); + + it('should preserve function metadata', () => { + const getMetadataKeysSpy = vi.spyOn(Reflect, 'getMetadataKeys').mockReturnValue(['cron-key']); + const getMetadataSpy = vi.spyOn(Reflect, 'getMetadata').mockReturnValue('cron-value'); + const defineMetadataSpy = vi.spyOn(Reflect, 'defineMetadata').mockImplementation(() => {}); + + const originalMethod = () => 'cron result'; + const descriptor = { + value: originalMethod, + writable: true, + configurable: true, + enumerable: true, + }; + + const decoratedDescriptor = SentryCron('monitor-slug')({}, 'cronMethod', descriptor); + typeof decoratedDescriptor?.value === 'function' && decoratedDescriptor.value(); + + expect(getMetadataKeysSpy).toHaveBeenCalled(); + expect(getMetadataSpy).toHaveBeenCalled(); + expect(defineMetadataSpy).toHaveBeenCalled(); + + getMetadataKeysSpy.mockRestore(); + getMetadataSpy.mockRestore(); + defineMetadataSpy.mockRestore(); + }); +}); + +describe('SentryExceptionCaptured decorator', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should capture non-expected exceptions', () => { + const captureExceptionSpy = vi.spyOn(core, 'captureException'); + const isExpectedErrorSpy = vi.spyOn(helpers, 'isExpectedError').mockReturnValue(false); + + const originalCatch = vi.fn().mockImplementation((exception, host) => { + return { exception, host }; + }); + + const descriptor: PropertyDescriptor = { + value: originalCatch, + writable: true, + enumerable: true, + configurable: true, + }; + + const decoratedDescriptor = SentryExceptionCaptured()( + {}, // target + 'catch', + descriptor, + ); + + const decoratedMethod = decoratedDescriptor.value; + const exception = new Error('Test exception'); + const host = { switchToHttp: () => ({}) }; + + decoratedMethod(exception, host); + + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).toHaveBeenCalledWith(exception); + expect(originalCatch).toHaveBeenCalledWith(exception, host); + + isExpectedErrorSpy.mockRestore(); + }); + + it('should not capture expected exceptions', () => { + const captureExceptionSpy = vi.spyOn(core, 'captureException'); + const isExpectedErrorSpy = vi.spyOn(helpers, 'isExpectedError').mockReturnValue(true); + + const originalCatch = vi.fn().mockImplementation((exception, host) => { + return { exception, host }; + }); + + const descriptor: PropertyDescriptor = { + value: originalCatch, + writable: true, + enumerable: true, + configurable: true, + }; + + const decoratedDescriptor = SentryExceptionCaptured()( + {}, // target + 'catch', + descriptor, + ); + + const decoratedMethod = decoratedDescriptor.value; + const exception = new Error('Expected exception'); + const host = { switchToHttp: () => ({}) }; + + decoratedMethod(exception, host); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + expect(originalCatch).toHaveBeenCalledWith(exception, host); + + isExpectedErrorSpy.mockRestore(); + }); + + it('should preserve function metadata', () => { + const getMetadataKeysSpy = vi.spyOn(Reflect, 'getMetadataKeys').mockReturnValue(['exception-key']); + const getMetadataSpy = vi.spyOn(Reflect, 'getMetadata').mockReturnValue('exception-value'); + const defineMetadataSpy = vi.spyOn(Reflect, 'defineMetadata').mockImplementation(() => {}); + + const originalMethod = () => ({ handled: true }); + const descriptor = { + value: originalMethod, + writable: true, + configurable: true, + enumerable: true, + }; + + const decoratedDescriptor = SentryExceptionCaptured()({}, 'catch', descriptor); + vi.spyOn(helpers, 'isExpectedError').mockReturnValue(true); + + decoratedDescriptor.value(new Error(), {}); + + expect(getMetadataKeysSpy).toHaveBeenCalled(); + expect(getMetadataSpy).toHaveBeenCalled(); + expect(defineMetadataSpy).toHaveBeenCalled(); + + getMetadataKeysSpy.mockRestore(); + getMetadataSpy.mockRestore(); + defineMetadataSpy.mockRestore(); + }); + + it('should handle additional arguments', () => { + const captureExceptionSpy = vi.spyOn(core, 'captureException'); + vi.spyOn(helpers, 'isExpectedError').mockReturnValue(false); + + const originalCatch = vi.fn().mockImplementation((exception, host, arg1, arg2) => { + return { exception, host, arg1, arg2 }; + }); + + const descriptor: PropertyDescriptor = { + value: originalCatch, + writable: true, + enumerable: true, + configurable: true, + }; + + const decoratedDescriptor = SentryExceptionCaptured()( + {}, // target + 'catch', + descriptor, + ); + + const decoratedMethod = decoratedDescriptor.value; + const exception = new Error('Test exception'); + const host = { switchToHttp: () => ({}) }; + const arg1 = 'extra1'; + const arg2 = 'extra2'; + + decoratedMethod(exception, host, arg1, arg2); + + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + expect(originalCatch).toHaveBeenCalledWith(exception, host, arg1, arg2); + }); +}); diff --git a/yarn.lock b/yarn.lock index 8251fd18b43d..30fcfd7c1c7f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -26339,6 +26339,11 @@ reflect-metadata@^0.1.2: resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08" integrity sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg== +reflect-metadata@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz#400c845b6cba87a21f2c65c4aeb158f4fa4d9c5b" + integrity sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q== + regenerate-unicode-properties@^10.1.0: version "10.1.0" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz#7c3192cab6dd24e21cb4461e5ddd7dd24fa8374c" From f3774969f63879cfb158602bfab4b07dc8a5de7e Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Wed, 5 Mar 2025 19:19:13 +0000 Subject: [PATCH 07/14] fix(remix): Use correct types export for `@sentry/remix/cloudflare` (#15599) --- packages/remix/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/remix/package.json b/packages/remix/package.json index f9fa312a2d3b..94f8e81625b0 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -33,7 +33,7 @@ "./cloudflare": { "import": "./build/esm/cloudflare/index.js", "require": "./build/cjs/cloudflare/index.js", - "types": "./build/types/cloudflare/index.types.d.ts", + "types": "./build/types/cloudflare/index.d.ts", "default": "./build/esm/cloudflare/index.js" }, "./import": { From 07d23bf3ac0043b1e1a58a611abeaae4297daef2 Mon Sep 17 00:00:00 2001 From: Nick Amoscato Date: Thu, 6 Mar 2025 06:30:57 -0500 Subject: [PATCH 08/14] fix(react-router): Fix config type import (#15583) --------- Co-authored-by: Charly Gomez --- packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts | 2 +- packages/react-router/tsconfig.json | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts b/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts index ee89cf1a09e3..ce45f57a8db3 100644 --- a/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts +++ b/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts @@ -1,5 +1,5 @@ import { rm } from 'node:fs/promises'; -import type { Config } from '@react-router/dev/dist/config'; +import type { Config } from '@react-router/dev/config'; import SentryCli from '@sentry/cli'; import { glob } from 'glob'; import type { SentryReactRouterBuildOptions } from '../types'; diff --git a/packages/react-router/tsconfig.json b/packages/react-router/tsconfig.json index b0eb9ecb6476..5f80a125a0dc 100644 --- a/packages/react-router/tsconfig.json +++ b/packages/react-router/tsconfig.json @@ -3,5 +3,7 @@ "include": ["src/**/*"], - "compilerOptions": {} + "compilerOptions": { + "moduleResolution": "bundler" + } } From e68cbec2ac1fb35699339ee3020a2327e09caf67 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Thu, 6 Mar 2025 12:51:20 +0100 Subject: [PATCH 09/14] chore: Add external contributor to CHANGELOG.md (#15604) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #15583 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 5c1f7eed56f0..5cadfe72a1c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +Work in this release was contributed by @namoscato. Thank you for your contribution! + ## 9.4.0 - feat(core): Add types for logs protocol and envelope ([#15530](https://github.com/getsentry/sentry-javascript/pull/15530)) From e3692a425d94a9854f29be9c0209233aff7c7ab3 Mon Sep 17 00:00:00 2001 From: Riley Date: Thu, 6 Mar 2025 23:43:51 +1000 Subject: [PATCH 10/14] Add cloudflare adapter detection and path generation (#15603) Before submitting a pull request, please take a look at our [Contributing](https://github.com/getsentry/sentry-javascript/blob/master/CONTRIBUTING.md) guidelines and verify: - [x] If you've added code that should be tested, please add tests. - [x] Ensure your code lints and the test suite passes (`yarn lint`) & (`yarn test`). This PR expands upon #14672 by adding detection of the @sveltejs/adapter-cloudflare adapter and defining it's output directory (cloudflare as opposed to output). I've tested it with a Cloudflare Pages project and it was successful in building and uploading sourcemaps, whereas relying on 'other' caused an infinite loop and an OOM when trying to upload sourcemaps. --- packages/sveltekit/src/vite/detectAdapter.ts | 4 +++- packages/sveltekit/src/vite/svelteConfig.ts | 4 ++++ packages/sveltekit/test/vite/detectAdapter.test.ts | 6 +++++- packages/sveltekit/test/vite/svelteConfig.test.ts | 5 +++++ 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/sveltekit/src/vite/detectAdapter.ts b/packages/sveltekit/src/vite/detectAdapter.ts index e979eb0c43e4..85b794575c93 100644 --- a/packages/sveltekit/src/vite/detectAdapter.ts +++ b/packages/sveltekit/src/vite/detectAdapter.ts @@ -5,7 +5,7 @@ import type { Package } from '@sentry/core'; /** * Supported @sveltejs/adapters-[adapter] SvelteKit adapters */ -export type SupportedSvelteKitAdapters = 'node' | 'auto' | 'vercel' | 'other'; +export type SupportedSvelteKitAdapters = 'node' | 'auto' | 'vercel' | 'cloudflare' | 'other'; /** * Tries to detect the used adapter for SvelteKit by looking at the dependencies. @@ -21,6 +21,8 @@ export async function detectAdapter(debug?: boolean): Promise { const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - it.each(['auto', 'vercel', 'node'])( + it.each(['auto', 'vercel', 'node', 'cloudflare'])( 'returns the adapter name (adapter %s) and logs it to the console', async adapter => { pkgJson.dependencies[`@sveltejs/adapter-${adapter}`] = '1.0.0'; @@ -68,6 +68,7 @@ describe('detectAdapter', () => { pkgJson.dependencies['@sveltejs/adapter-auto'] = '1.0.0'; pkgJson.dependencies['@sveltejs/adapter-vercel'] = '1.0.0'; pkgJson.dependencies['@sveltejs/adapter-node'] = '1.0.0'; + pkgJson.dependencies['@sveltejs/adapter-cloudflare'] = '1.0.0'; const detectedAdapter = await detectAdapter(); expect(detectedAdapter).toEqual('vercel'); @@ -75,5 +76,8 @@ describe('detectAdapter', () => { delete pkgJson.dependencies['@sveltejs/adapter-vercel']; const detectedAdapter2 = await detectAdapter(); expect(detectedAdapter2).toEqual('node'); + delete pkgJson.dependencies['@sveltejs/adapter-node']; + const detectedAdapter3 = await detectAdapter(); + expect(detectedAdapter3).toEqual('cloudflare'); }); }); diff --git a/packages/sveltekit/test/vite/svelteConfig.test.ts b/packages/sveltekit/test/vite/svelteConfig.test.ts index 35e32ce8ac4d..a567407b6623 100644 --- a/packages/sveltekit/test/vite/svelteConfig.test.ts +++ b/packages/sveltekit/test/vite/svelteConfig.test.ts @@ -68,6 +68,11 @@ describe('getAdapterOutputDir', () => { expect(outputDir).toEqual('customBuildDir'); }); + it('returns the output directory of the Cloudflare adapter', async () => { + const outputDir = await getAdapterOutputDir({ kit: { outDir: 'customOutDir' } }, 'cloudflare'); + expect(outputDir).toEqual('customOutDir/cloudflare'); + }); + it.each(['vercel', 'auto', 'other'] as SupportedSvelteKitAdapters[])( 'returns the config.kit.outdir directory for adapter-%s', async adapter => { From a4371a2186c0e4de91dff489018499890fd76c09 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Thu, 6 Mar 2025 15:51:51 +0100 Subject: [PATCH 11/14] chore: Add external contributor to CHANGELOG.md (#15595) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #15576 Co-authored-by: AbhiPrasad <18689448+AbhiPrasad@users.noreply.github.com> Co-authored-by: Charly Gomez --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cadfe72a1c3..53028d06c534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -Work in this release was contributed by @namoscato. Thank you for your contribution! +Work in this release was contributed by @msurdi-a8c and @namoscato. Thank you for your contributions! ## 9.4.0 From 44a35079f9797b3d0f79d23f493b2c0ea6a9ae06 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Thu, 6 Mar 2025 17:13:53 +0100 Subject: [PATCH 12/14] chore: Add external contributor to CHANGELOG.md (#15606) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #15603 --------- Co-authored-by: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Co-authored-by: Abhijeet Prasad --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53028d06c534..69f3c0965f6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -Work in this release was contributed by @msurdi-a8c and @namoscato. Thank you for your contributions! +Work in this release was contributed by @msurdi-a8c, @namoscato, and @rileyg98. Thank you for your contributions! ## 9.4.0 From 8d9f523661416b7a749beb4031f9ae21edff0db3 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Thu, 6 Mar 2025 11:39:49 -0500 Subject: [PATCH 13/14] Revert "feat(feedback) Allowing annotation via highlighting & masking (#15484)" (#15609) Screenshots without annotations were not showing up properly (they are being capture as completely transparent). This reverts commit 9c1f79b79308f046eaee90710e4ae5ff449c3bc5. --- .../core/src/types-hoist/feedback/config.ts | 9 + packages/feedback/src/constants/index.ts | 4 +- packages/feedback/src/core/integration.ts | 3 + .../src/screenshot/components/Annotations.tsx | 91 +++++ .../src/screenshot/components/Crop.tsx | 338 ++++++++++++++++ .../src/screenshot/components/CropCorner.tsx | 38 ++ .../src/screenshot/components/CropIcon.tsx | 23 ++ .../src/screenshot/components/IconClose.tsx | 29 -- .../src/screenshot/components/PenIcon.tsx | 31 ++ .../components/ScreenshotEditor.tsx | 364 ++++-------------- .../components/ScreenshotInput.css.ts | 103 +++-- .../src/screenshot/components/Toolbar.tsx | 35 +- yarn.lock | 1 + 13 files changed, 703 insertions(+), 366 deletions(-) create mode 100644 packages/feedback/src/screenshot/components/Annotations.tsx create mode 100644 packages/feedback/src/screenshot/components/Crop.tsx create mode 100644 packages/feedback/src/screenshot/components/CropCorner.tsx create mode 100644 packages/feedback/src/screenshot/components/CropIcon.tsx delete mode 100644 packages/feedback/src/screenshot/components/IconClose.tsx create mode 100644 packages/feedback/src/screenshot/components/PenIcon.tsx diff --git a/packages/core/src/types-hoist/feedback/config.ts b/packages/core/src/types-hoist/feedback/config.ts index 4ec846c7d98d..d7b3d78995bb 100644 --- a/packages/core/src/types-hoist/feedback/config.ts +++ b/packages/core/src/types-hoist/feedback/config.ts @@ -57,6 +57,15 @@ export interface FeedbackGeneralConfiguration { name: string; }; + /** + * _experiments allows users to enable experimental or internal features. + * We don't consider such features as part of the public API and hence we don't guarantee semver for them. + * Experimental features can be added, changed or removed at any time. + * + * Default: undefined + */ + _experiments: Partial<{ annotations: boolean }>; + /** * Set an object that will be merged sent as tags data with the event. */ diff --git a/packages/feedback/src/constants/index.ts b/packages/feedback/src/constants/index.ts index b568fb615ccb..198b6e199bb5 100644 --- a/packages/feedback/src/constants/index.ts +++ b/packages/feedback/src/constants/index.ts @@ -20,8 +20,8 @@ export const NAME_PLACEHOLDER = 'Your Name'; export const NAME_LABEL = 'Name'; export const SUCCESS_MESSAGE_TEXT = 'Thank you for your report!'; export const IS_REQUIRED_LABEL = '(required)'; -export const ADD_SCREENSHOT_LABEL = 'Capture Screenshot'; -export const REMOVE_SCREENSHOT_LABEL = 'Remove Screenshot'; +export const ADD_SCREENSHOT_LABEL = 'Add a screenshot'; +export const REMOVE_SCREENSHOT_LABEL = 'Remove screenshot'; export const FEEDBACK_WIDGET_SOURCE = 'widget'; export const FEEDBACK_API_SOURCE = 'api'; diff --git a/packages/feedback/src/core/integration.ts b/packages/feedback/src/core/integration.ts index e5f1092856f1..8b312b902258 100644 --- a/packages/feedback/src/core/integration.ts +++ b/packages/feedback/src/core/integration.ts @@ -84,6 +84,7 @@ export const buildFeedbackIntegration = ({ email: 'email', name: 'username', }, + _experiments = {}, tags, styleNonce, scriptNonce, @@ -158,6 +159,8 @@ export const buildFeedbackIntegration = ({ onSubmitError, onSubmitSuccess, onFormSubmitted, + + _experiments, }; let _shadow: ShadowRoot | null = null; diff --git a/packages/feedback/src/screenshot/components/Annotations.tsx b/packages/feedback/src/screenshot/components/Annotations.tsx new file mode 100644 index 000000000000..eb897b40f166 --- /dev/null +++ b/packages/feedback/src/screenshot/components/Annotations.tsx @@ -0,0 +1,91 @@ +import type { VNode, h as hType } from 'preact'; +import type * as Hooks from 'preact/hooks'; +import { DOCUMENT } from '../../constants'; + +interface FactoryParams { + h: typeof hType; +} + +export default function AnnotationsFactory({ + h, // eslint-disable-line @typescript-eslint/no-unused-vars +}: FactoryParams) { + return function Annotations({ + action, + imageBuffer, + annotatingRef, + }: { + action: 'crop' | 'annotate' | ''; + imageBuffer: HTMLCanvasElement; + annotatingRef: Hooks.Ref; + }): VNode { + const onAnnotateStart = (): void => { + if (action !== 'annotate') { + return; + } + + const handleMouseMove = (moveEvent: MouseEvent): void => { + const annotateCanvas = annotatingRef.current; + if (annotateCanvas) { + const rect = annotateCanvas.getBoundingClientRect(); + const x = moveEvent.clientX - rect.x; + const y = moveEvent.clientY - rect.y; + + const ctx = annotateCanvas.getContext('2d'); + if (ctx) { + ctx.lineTo(x, y); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(x, y); + } + } + }; + + const handleMouseUp = (): void => { + const ctx = annotatingRef.current?.getContext('2d'); + if (ctx) { + ctx.beginPath(); + } + + // Add your apply annotation logic here + applyAnnotation(); + + DOCUMENT.removeEventListener('mousemove', handleMouseMove); + DOCUMENT.removeEventListener('mouseup', handleMouseUp); + }; + + DOCUMENT.addEventListener('mousemove', handleMouseMove); + DOCUMENT.addEventListener('mouseup', handleMouseUp); + }; + + const applyAnnotation = (): void => { + // Logic to apply the annotation + const imageCtx = imageBuffer.getContext('2d'); + const annotateCanvas = annotatingRef.current; + if (imageCtx && annotateCanvas) { + imageCtx.drawImage( + annotateCanvas, + 0, + 0, + annotateCanvas.width, + annotateCanvas.height, + 0, + 0, + imageBuffer.width, + imageBuffer.height, + ); + + const annotateCtx = annotateCanvas.getContext('2d'); + if (annotateCtx) { + annotateCtx.clearRect(0, 0, annotateCanvas.width, annotateCanvas.height); + } + } + }; + return ( + + ); + }; +} diff --git a/packages/feedback/src/screenshot/components/Crop.tsx b/packages/feedback/src/screenshot/components/Crop.tsx new file mode 100644 index 000000000000..3b31ee71573c --- /dev/null +++ b/packages/feedback/src/screenshot/components/Crop.tsx @@ -0,0 +1,338 @@ +import type { FeedbackInternalOptions } from '@sentry/core'; +import type { VNode, h as hType } from 'preact'; +import type * as Hooks from 'preact/hooks'; +import { DOCUMENT, WINDOW } from '../../constants'; +import CropCornerFactory from './CropCorner'; + +const CROP_BUTTON_SIZE = 30; +const CROP_BUTTON_BORDER = 3; +const CROP_BUTTON_OFFSET = CROP_BUTTON_SIZE + CROP_BUTTON_BORDER; +const DPI = WINDOW.devicePixelRatio; + +interface Box { + startX: number; + startY: number; + endX: number; + endY: number; +} + +interface Rect { + x: number; + y: number; + height: number; + width: number; +} + +const constructRect = (box: Box): Rect => ({ + x: Math.min(box.startX, box.endX), + y: Math.min(box.startY, box.endY), + width: Math.abs(box.startX - box.endX), + height: Math.abs(box.startY - box.endY), +}); + +const getContainedSize = (img: HTMLCanvasElement): Rect => { + const imgClientHeight = img.clientHeight; + const imgClientWidth = img.clientWidth; + const ratio = img.width / img.height; + let width = imgClientHeight * ratio; + let height = imgClientHeight; + if (width > imgClientWidth) { + width = imgClientWidth; + height = imgClientWidth / ratio; + } + const x = (imgClientWidth - width) / 2; + const y = (imgClientHeight - height) / 2; + return { x: x, y: y, width: width, height: height }; +}; + +interface FactoryParams { + h: typeof hType; + hooks: typeof Hooks; + options: FeedbackInternalOptions; +} + +export default function CropFactory({ + h, + hooks, + options, +}: FactoryParams): (props: { + action: 'crop' | 'annotate' | ''; + imageBuffer: HTMLCanvasElement; + croppingRef: Hooks.Ref; + cropContainerRef: Hooks.Ref; + croppingRect: Box; + setCroppingRect: Hooks.StateUpdater; + resize: () => void; +}) => VNode { + const CropCorner = CropCornerFactory({ h }); + return function Crop({ + action, + imageBuffer, + croppingRef, + cropContainerRef, + croppingRect, + setCroppingRect, + resize, + }: { + action: 'crop' | 'annotate' | ''; + imageBuffer: HTMLCanvasElement; + croppingRef: Hooks.Ref; + cropContainerRef: Hooks.Ref; + croppingRect: Box; + setCroppingRect: Hooks.StateUpdater; + resize: () => void; + }): VNode { + const initialPositionRef = hooks.useRef({ initialX: 0, initialY: 0 }); + + const [isResizing, setIsResizing] = hooks.useState(false); + const [confirmCrop, setConfirmCrop] = hooks.useState(false); + + hooks.useEffect(() => { + const cropper = croppingRef.current; + if (!cropper) { + return; + } + + const ctx = cropper.getContext('2d'); + if (!ctx) { + return; + } + + const imageDimensions = getContainedSize(imageBuffer); + const croppingBox = constructRect(croppingRect); + ctx.clearRect(0, 0, imageDimensions.width, imageDimensions.height); + + if (action !== 'crop') { + return; + } + + // draw gray overlay around the selection + ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; + ctx.fillRect(0, 0, imageDimensions.width, imageDimensions.height); + ctx.clearRect(croppingBox.x, croppingBox.y, croppingBox.width, croppingBox.height); + + // draw selection border + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 3; + ctx.strokeRect(croppingBox.x + 1, croppingBox.y + 1, croppingBox.width - 2, croppingBox.height - 2); + ctx.strokeStyle = '#000000'; + ctx.lineWidth = 1; + ctx.strokeRect(croppingBox.x + 3, croppingBox.y + 3, croppingBox.width - 6, croppingBox.height - 6); + }, [croppingRect, action]); + + // Resizing logic + const makeHandleMouseMove = hooks.useCallback((corner: string) => { + return (e: MouseEvent) => { + if (!croppingRef.current) { + return; + } + + const cropCanvas = croppingRef.current; + const cropBoundingRect = cropCanvas.getBoundingClientRect(); + const mouseX = e.clientX - cropBoundingRect.x; + const mouseY = e.clientY - cropBoundingRect.y; + + switch (corner) { + case 'top-left': + setCroppingRect(prev => ({ + ...prev, + startX: Math.min(Math.max(0, mouseX), prev.endX - CROP_BUTTON_OFFSET), + startY: Math.min(Math.max(0, mouseY), prev.endY - CROP_BUTTON_OFFSET), + })); + break; + case 'top-right': + setCroppingRect(prev => ({ + ...prev, + endX: Math.max(Math.min(mouseX, cropCanvas.width / DPI), prev.startX + CROP_BUTTON_OFFSET), + startY: Math.min(Math.max(0, mouseY), prev.endY - CROP_BUTTON_OFFSET), + })); + break; + case 'bottom-left': + setCroppingRect(prev => ({ + ...prev, + startX: Math.min(Math.max(0, mouseX), prev.endX - CROP_BUTTON_OFFSET), + endY: Math.max(Math.min(mouseY, cropCanvas.height / DPI), prev.startY + CROP_BUTTON_OFFSET), + })); + break; + case 'bottom-right': + setCroppingRect(prev => ({ + ...prev, + endX: Math.max(Math.min(mouseX, cropCanvas.width / DPI), prev.startX + CROP_BUTTON_OFFSET), + endY: Math.max(Math.min(mouseY, cropCanvas.height / DPI), prev.startY + CROP_BUTTON_OFFSET), + })); + break; + } + }; + }, []); + + // Dragging logic + const onDragStart = (e: MouseEvent): void => { + if (isResizing) { + return; + } + + initialPositionRef.current = { initialX: e.clientX, initialY: e.clientY }; + + const handleMouseMove = (moveEvent: MouseEvent): void => { + const cropCanvas = croppingRef.current; + if (!cropCanvas) { + return; + } + + const deltaX = moveEvent.clientX - initialPositionRef.current.initialX; + const deltaY = moveEvent.clientY - initialPositionRef.current.initialY; + + setCroppingRect(prev => { + const newStartX = Math.max( + 0, + Math.min(prev.startX + deltaX, cropCanvas.width / DPI - (prev.endX - prev.startX)), + ); + const newStartY = Math.max( + 0, + Math.min(prev.startY + deltaY, cropCanvas.height / DPI - (prev.endY - prev.startY)), + ); + + const newEndX = newStartX + (prev.endX - prev.startX); + const newEndY = newStartY + (prev.endY - prev.startY); + + initialPositionRef.current.initialX = moveEvent.clientX; + initialPositionRef.current.initialY = moveEvent.clientY; + + return { startX: newStartX, startY: newStartY, endX: newEndX, endY: newEndY }; + }); + }; + + const handleMouseUp = (): void => { + DOCUMENT.removeEventListener('mousemove', handleMouseMove); + DOCUMENT.removeEventListener('mouseup', handleMouseUp); + }; + + DOCUMENT.addEventListener('mousemove', handleMouseMove); + DOCUMENT.addEventListener('mouseup', handleMouseUp); + }; + + const onGrabButton = (e: Event, corner: string): void => { + setIsResizing(true); + const handleMouseMove = makeHandleMouseMove(corner); + const handleMouseUp = (): void => { + DOCUMENT.removeEventListener('mousemove', handleMouseMove); + DOCUMENT.removeEventListener('mouseup', handleMouseUp); + setConfirmCrop(true); + setIsResizing(false); + }; + + DOCUMENT.addEventListener('mouseup', handleMouseUp); + DOCUMENT.addEventListener('mousemove', handleMouseMove); + }; + + function applyCrop(): void { + const cutoutCanvas = DOCUMENT.createElement('canvas'); + const imageBox = getContainedSize(imageBuffer); + const croppingBox = constructRect(croppingRect); + cutoutCanvas.width = croppingBox.width * DPI; + cutoutCanvas.height = croppingBox.height * DPI; + + const cutoutCtx = cutoutCanvas.getContext('2d'); + if (cutoutCtx && imageBuffer) { + cutoutCtx.drawImage( + imageBuffer, + (croppingBox.x / imageBox.width) * imageBuffer.width, + (croppingBox.y / imageBox.height) * imageBuffer.height, + (croppingBox.width / imageBox.width) * imageBuffer.width, + (croppingBox.height / imageBox.height) * imageBuffer.height, + 0, + 0, + cutoutCanvas.width, + cutoutCanvas.height, + ); + } + + const ctx = imageBuffer.getContext('2d'); + if (ctx) { + ctx.clearRect(0, 0, imageBuffer.width, imageBuffer.height); + imageBuffer.width = cutoutCanvas.width; + imageBuffer.height = cutoutCanvas.height; + imageBuffer.style.width = `${croppingBox.width}px`; + imageBuffer.style.height = `${croppingBox.height}px`; + ctx.drawImage(cutoutCanvas, 0, 0); + + resize(); + } + } + + return ( +
+ + {action === 'crop' && ( +
+ + + + +
+ )} + {action === 'crop' && ( +
+ + +
+ )} +
+ ); + }; +} diff --git a/packages/feedback/src/screenshot/components/CropCorner.tsx b/packages/feedback/src/screenshot/components/CropCorner.tsx new file mode 100644 index 000000000000..de3b6e506e71 --- /dev/null +++ b/packages/feedback/src/screenshot/components/CropCorner.tsx @@ -0,0 +1,38 @@ +import type { VNode, h as hType } from 'preact'; + +interface FactoryParams { + h: typeof hType; +} + +export default function CropCornerFactory({ + h, // eslint-disable-line @typescript-eslint/no-unused-vars +}: FactoryParams) { + return function CropCorner({ + top, + left, + corner, + onGrabButton, + }: { + top: number; + left: number; + corner: string; + onGrabButton: (e: Event, corner: string) => void; + }): VNode { + return ( + + ); + }; +} diff --git a/packages/feedback/src/screenshot/components/CropIcon.tsx b/packages/feedback/src/screenshot/components/CropIcon.tsx new file mode 100644 index 000000000000..091179d86004 --- /dev/null +++ b/packages/feedback/src/screenshot/components/CropIcon.tsx @@ -0,0 +1,23 @@ +import type { VNode, h as hType } from 'preact'; + +interface FactoryParams { + h: typeof hType; +} + +export default function CropIconFactory({ + h, // eslint-disable-line @typescript-eslint/no-unused-vars +}: FactoryParams) { + return function CropIcon(): VNode { + return ( + + + + ); + }; +} diff --git a/packages/feedback/src/screenshot/components/IconClose.tsx b/packages/feedback/src/screenshot/components/IconClose.tsx deleted file mode 100644 index dea383a61839..000000000000 --- a/packages/feedback/src/screenshot/components/IconClose.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { VNode, h as hType } from 'preact'; - -interface FactoryParams { - h: typeof hType; -} - -export default function IconCloseFactory({ - h, // eslint-disable-line @typescript-eslint/no-unused-vars -}: FactoryParams) { - return function IconClose(): VNode { - return ( - - - - - - - ); - }; -} diff --git a/packages/feedback/src/screenshot/components/PenIcon.tsx b/packages/feedback/src/screenshot/components/PenIcon.tsx new file mode 100644 index 000000000000..75a0faedf480 --- /dev/null +++ b/packages/feedback/src/screenshot/components/PenIcon.tsx @@ -0,0 +1,31 @@ +import type { VNode, h as hType } from 'preact'; + +interface FactoryParams { + h: typeof hType; +} + +export default function PenIconFactory({ + h, // eslint-disable-line @typescript-eslint/no-unused-vars +}: FactoryParams) { + return function PenIcon(): VNode { + return ( + + + + + + ); + }; +} diff --git a/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx b/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx index 346a380d399d..9e8e708ec580 100644 --- a/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx +++ b/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx @@ -1,14 +1,16 @@ -/* eslint-disable max-lines */ import type { FeedbackInternalOptions, FeedbackModalIntegration } from '@sentry/core'; import type { ComponentType, VNode, h as hType } from 'preact'; import { h } from 'preact'; // eslint-disable-line @typescript-eslint/no-unused-vars import type * as Hooks from 'preact/hooks'; -import { DOCUMENT, WINDOW } from '../../constants'; -import IconCloseFactory from './IconClose'; +import { WINDOW } from '../../constants'; +import AnnotationsFactory from './Annotations'; +import CropFactory from './Crop'; import { createScreenshotInputStyles } from './ScreenshotInput.css'; import ToolbarFactory from './Toolbar'; import { useTakeScreenshotFactory } from './useTakeScreenshot'; +const DPI = WINDOW.devicePixelRatio; + interface FactoryParams { h: typeof hType; hooks: typeof Hooks; @@ -21,8 +23,6 @@ interface Props { onError: (error: Error) => void; } -type Action = 'highlight' | 'hide'; - interface Box { startX: number; startY: number; @@ -30,31 +30,17 @@ interface Box { endY: number; } -interface Dimensions { +interface Rect { x: number; y: number; height: number; width: number; } -interface Rect extends Dimensions { - action: Action; -} - -const DPI = WINDOW.devicePixelRatio; - -const constructRect = (action: Action, box: Box): Rect => ({ - action, - x: Math.min(box.startX, box.endX), - y: Math.min(box.startY, box.endY), - width: Math.abs(box.startX - box.endX), - height: Math.abs(box.startY - box.endY), -}); - -const getContainedSize = (measurementDiv: HTMLDivElement, imageSource: HTMLCanvasElement): Dimensions => { - const imgClientHeight = measurementDiv.clientHeight; - const imgClientWidth = measurementDiv.clientWidth; - const ratio = imageSource.width / imageSource.height; +const getContainedSize = (img: HTMLCanvasElement): Rect => { + const imgClientHeight = img.clientHeight; + const imgClientWidth = img.clientWidth; + const ratio = img.width / img.height; let width = imgClientHeight * ratio; let height = imgClientHeight; if (width > imgClientWidth) { @@ -66,53 +52,6 @@ const getContainedSize = (measurementDiv: HTMLDivElement, imageSource: HTMLCanva return { x: x, y: y, width: width, height: height }; }; -function drawRect(rect: Rect, ctx: CanvasRenderingContext2D, color: string, scale: number = 1): void { - const scaledX = rect.x * scale; - const scaledY = rect.y * scale; - const scaledWidth = rect.width * scale; - const scaledHeight = rect.height * scale; - - switch (rect.action) { - case 'highlight': { - // creates a shadow around - ctx.shadowColor = 'rgba(0, 0, 0, 0.7)'; - ctx.shadowBlur = 50; - - // draws a rectangle first so that the shadow is visible before clearing - ctx.fillStyle = 'rgb(0, 0, 0)'; - ctx.fillRect(scaledX, scaledY, scaledWidth, scaledHeight); - ctx.clearRect(scaledX, scaledY, scaledWidth, scaledHeight); - - // Disable shadow after the action is drawn - ctx.shadowColor = 'transparent'; - ctx.shadowBlur = 0; - - ctx.strokeStyle = color; - ctx.strokeRect(scaledX + 1, scaledY + 1, scaledWidth - 2, scaledHeight - 2); - - break; - } - case 'hide': - ctx.fillStyle = 'rgb(0, 0, 0)'; - ctx.fillRect(scaledX, scaledY, scaledWidth, scaledHeight); - - break; - default: - break; - } -} - -function resizeCanvas(canvas: HTMLCanvasElement, imageDimensions: Dimensions): void { - canvas.width = imageDimensions.width * DPI; - canvas.height = imageDimensions.height * DPI; - canvas.style.width = `${imageDimensions.width}px`; - canvas.style.height = `${imageDimensions.height}px`; - const ctx = canvas.getContext('2d'); - if (ctx) { - ctx.scale(DPI, DPI); - } -} - export function ScreenshotEditorFactory({ h, hooks, @@ -122,73 +61,23 @@ export function ScreenshotEditorFactory({ }: FactoryParams): ComponentType { const useTakeScreenshot = useTakeScreenshotFactory({ hooks }); const Toolbar = ToolbarFactory({ h }); - const IconClose = IconCloseFactory({ h }); - const styles = { __html: createScreenshotInputStyles(options.styleNonce).innerText }; + const Annotations = AnnotationsFactory({ h }); + const Crop = CropFactory({ h, hooks, options }); return function ScreenshotEditor({ onError }: Props): VNode { - // Data for rendering: - const [action, setAction] = hooks.useState('highlight'); - const [drawRects, setDrawRects] = hooks.useState([]); - const [currentRect, setCurrentRect] = hooks.useState(undefined); + const styles = hooks.useMemo(() => ({ __html: createScreenshotInputStyles(options.styleNonce).innerText }), []); - // Refs to our html components: - const measurementRef = hooks.useRef(null); - const screenshotRef = hooks.useRef(null); + const canvasContainerRef = hooks.useRef(null); + const cropContainerRef = hooks.useRef(null); const annotatingRef = hooks.useRef(null); - const rectContainerRef = hooks.useRef(null); - - // The canvas that contains the original screenshot - const [imageSource, setImageSource] = hooks.useState(null); - - // Hide the whole feedback widget when we take the screenshot - const [displayEditor, setDisplayEditor] = hooks.useState(true); - - // The size of our window, relative to the imageSource - const [scaleFactor, setScaleFactor] = hooks.useState(1); - - const strokeColor = hooks.useMemo((): string => { - const sentryFeedback = DOCUMENT.getElementById(options.id); - if (!sentryFeedback) { - return 'white'; - } - const computedStyle = getComputedStyle(sentryFeedback); - return ( - computedStyle.getPropertyValue('--button-primary-background') || - computedStyle.getPropertyValue('--accent-background') - ); - }, [options.id]); - - const resize = hooks.useCallback((): void => { - if (!displayEditor) { - return; - } - - const screenshotCanvas = screenshotRef.current; - const annotatingCanvas = annotatingRef.current; - const measurementDiv = measurementRef.current; - const rectContainer = rectContainerRef.current; - if (!screenshotCanvas || !annotatingCanvas || !imageSource || !measurementDiv || !rectContainer) { - return; - } - - const imageDimensions = getContainedSize(measurementDiv, imageSource); - - resizeCanvas(screenshotCanvas, imageDimensions); - resizeCanvas(annotatingCanvas, imageDimensions); - - rectContainer.style.width = `${imageDimensions.width}px`; - rectContainer.style.height = `${imageDimensions.height}px`; - - const scale = annotatingCanvas.clientWidth / imageBuffer.width; - setScaleFactor(scale); - - const screenshotContext = screenshotCanvas.getContext('2d', { alpha: false }); - if (!screenshotContext) { - return; - } - screenshotContext.drawImage(imageSource, 0, 0, imageDimensions.width, imageDimensions.height); - drawScene(); - }, [imageSource, drawRects, displayEditor]); + const croppingRef = hooks.useRef(null); + const [action, setAction] = hooks.useState<'annotate' | 'crop' | ''>('crop'); + const [croppingRect, setCroppingRect] = hooks.useState({ + startX: 0, + startY: 0, + endX: 0, + endY: 0, + }); hooks.useEffect(() => { WINDOW.addEventListener('resize', resize); @@ -196,192 +85,87 @@ export function ScreenshotEditorFactory({ return () => { WINDOW.removeEventListener('resize', resize); }; - }, [resize]); - - hooks.useLayoutEffect(() => { - resize(); - }, [resize]); - - hooks.useEffect(() => { - drawScene(); - drawBuffer(); - }, [drawRects]); + }, []); - hooks.useEffect(() => { - if (currentRect) { - drawScene(); - } - }, [currentRect]); - - // draws the commands onto the imageBuffer, which is what's sent to Sentry - const drawBuffer = hooks.useCallback((): void => { - const ctx = imageBuffer.getContext('2d', { alpha: false }); - const measurementDiv = measurementRef.current; - if (!imageBuffer || !ctx || !imageSource || !measurementDiv) { + function resizeCanvas(canvasRef: Hooks.Ref, imageDimensions: Rect): void { + const canvas = canvasRef.current; + if (!canvas) { return; } - ctx.drawImage(imageSource, 0, 0); - - const annotatingBufferBig = DOCUMENT.createElement('canvas'); - annotatingBufferBig.width = imageBuffer.width; - annotatingBufferBig.height = imageBuffer.height; - - const grayCtx = annotatingBufferBig.getContext('2d'); - if (!grayCtx) { - return; + canvas.width = imageDimensions.width * DPI; + canvas.height = imageDimensions.height * DPI; + canvas.style.width = `${imageDimensions.width}px`; + canvas.style.height = `${imageDimensions.height}px`; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.scale(DPI, DPI); } + } - // applies the graywash if there's any boxes drawn - if (drawRects.length || currentRect) { - grayCtx.fillStyle = 'rgba(0, 0, 0, 0.25)'; - grayCtx.fillRect(0, 0, imageBuffer.width, imageBuffer.height); - } + function resize(): void { + const imageDimensions = getContainedSize(imageBuffer); - grayCtx.lineWidth = 4; - drawRects.forEach(rect => { - drawRect(rect, grayCtx, strokeColor); - }); - ctx.drawImage(annotatingBufferBig, 0, 0); - }, [drawRects, strokeColor]); + resizeCanvas(croppingRef, imageDimensions); + resizeCanvas(annotatingRef, imageDimensions); - const drawScene = hooks.useCallback((): void => { - const annotatingCanvas = annotatingRef.current; - if (!annotatingCanvas) { - return; + const cropContainer = cropContainerRef.current; + if (cropContainer) { + cropContainer.style.width = `${imageDimensions.width}px`; + cropContainer.style.height = `${imageDimensions.height}px`; } - const ctx = annotatingCanvas.getContext('2d'); - if (!ctx) { - return; - } - - ctx.clearRect(0, 0, annotatingCanvas.width, annotatingCanvas.height); - - // applies the graywash if there's any boxes drawn - if (drawRects.length || currentRect) { - ctx.fillStyle = 'rgba(0, 0, 0, 0.25)'; - ctx.fillRect(0, 0, annotatingCanvas.width, annotatingCanvas.height); - } - - ctx.lineWidth = 2; - const scale = annotatingCanvas.clientWidth / imageBuffer.width; - drawRects.forEach(rect => { - drawRect(rect, ctx, strokeColor, scale); - }); - - if (currentRect) { - drawRect(currentRect, ctx, strokeColor); - } - }, [drawRects, currentRect, strokeColor]); + setCroppingRect({ startX: 0, startY: 0, endX: imageDimensions.width, endY: imageDimensions.height }); + } useTakeScreenshot({ onBeforeScreenshot: hooks.useCallback(() => { (dialog.el as HTMLElement).style.display = 'none'; - setDisplayEditor(false); - }, []), - onScreenshot: hooks.useCallback((imageSource: HTMLVideoElement) => { - const bufferCanvas = DOCUMENT.createElement('canvas'); - bufferCanvas.width = imageSource.videoWidth; - bufferCanvas.height = imageSource.videoHeight; - bufferCanvas.getContext('2d', { alpha: false })?.drawImage(imageSource, 0, 0); - setImageSource(bufferCanvas); - - imageBuffer.width = imageSource.videoWidth; - imageBuffer.height = imageSource.videoHeight; }, []), + onScreenshot: hooks.useCallback( + (imageSource: HTMLVideoElement) => { + const context = imageBuffer.getContext('2d'); + if (!context) { + throw new Error('Could not get canvas context'); + } + imageBuffer.width = imageSource.videoWidth; + imageBuffer.height = imageSource.videoHeight; + imageBuffer.style.width = '100%'; + imageBuffer.style.height = '100%'; + context.drawImage(imageSource, 0, 0); + }, + [imageBuffer], + ), onAfterScreenshot: hooks.useCallback(() => { (dialog.el as HTMLElement).style.display = 'block'; - setDisplayEditor(true); + const container = canvasContainerRef.current; + container?.appendChild(imageBuffer); + resize(); }, []), onError: hooks.useCallback(error => { (dialog.el as HTMLElement).style.display = 'block'; - setDisplayEditor(true); onError(error); }, []), }); - const handleMouseDown = (e: MouseEvent): void => { - const annotatingCanvas = annotatingRef.current; - if (!action || !annotatingCanvas) { - return; - } - - const boundingRect = annotatingCanvas.getBoundingClientRect(); - - const startX = e.clientX - boundingRect.left; - const startY = e.clientY - boundingRect.top; - - const handleMouseMove = (e: MouseEvent): void => { - const endX = e.clientX - boundingRect.left; - const endY = e.clientY - boundingRect.top; - const rect = constructRect(action, { startX, startY, endX, endY }); - // prevent drawing when just clicking (not dragging) on the canvas - if (startX != endX && startY != endY) { - setCurrentRect(rect); - } - }; - - const handleMouseUp = (e: MouseEvent): void => { - // no rect is being drawn anymore, so setting active rect to undefined - setCurrentRect(undefined); - const endX = Math.max(0, Math.min(e.clientX - boundingRect.left, annotatingCanvas.width / DPI)); - const endY = Math.max(0, Math.min(e.clientY - boundingRect.top, annotatingCanvas.height / DPI)); - // prevent drawing a rect when just clicking (not dragging) on the canvas (ie. clicking delete) - if (startX != endX && startY != endY) { - // scale to image buffer - const scale = imageBuffer.width / annotatingCanvas.clientWidth; - const rect = constructRect(action, { - startX: startX * scale, - startY: startY * scale, - endX: endX * scale, - endY: endY * scale, - }); - setDrawRects(prev => [...prev, rect]); - } - - DOCUMENT.removeEventListener('mousemove', handleMouseMove); - DOCUMENT.removeEventListener('mouseup', handleMouseUp); - }; - - DOCUMENT.addEventListener('mousemove', handleMouseMove); - DOCUMENT.addEventListener('mouseup', handleMouseUp); - }; - - const handleDeleteRect = (index: number): void => { - const updatedRects = [...drawRects]; - updatedRects.splice(index, 1); - setDrawRects(updatedRects); - }; - return (