From eefa75660d3dcdb2c84a511f67789e4105a11327 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 5 Sep 2025 12:50:53 +0200 Subject: [PATCH 01/19] ci: Remove project automation workflow (#17508) We no longer use this, since we moved to linear. Noticed here: https://github.com/getsentry/sentry-javascript/pull/17503 which is also closed by this. --- .github/workflows/project-automation.yml | 97 ------------------------ 1 file changed, 97 deletions(-) delete mode 100644 .github/workflows/project-automation.yml diff --git a/.github/workflows/project-automation.yml b/.github/workflows/project-automation.yml deleted file mode 100644 index bf34f2e6078f..000000000000 --- a/.github/workflows/project-automation.yml +++ /dev/null @@ -1,97 +0,0 @@ -name: 'Automation: Update GH Project' -on: - pull_request: - types: - - closed - - opened - - reopened - - ready_for_review - - converted_to_draft - -jobs: - # Check if PR is in project - check_project: - name: Check if PR is in project - runs-on: ubuntu-latest - steps: - - name: Check if PR is in project - continue-on-error: true - id: check_project - uses: github/update-project-action@f980378bc179626af5b4e20ec05ec39c7f7a6f6d - with: - github_token: ${{ secrets.GH_PROJECT_AUTOMATION }} - organization: getsentry - project_number: 31 - content_id: ${{ github.event.pull_request.node_id }} - field: Status - operation: read - - - name: If project field is read, set is_in_project to 1 - if: steps.check_project.outputs.field_read_value - id: is_in_project - run: echo "is_in_project=1" >> "$GITHUB_OUTPUT" - - outputs: - is_in_project: ${{ steps.is_in_project.outputs.is_in_project || '0' }} - - # When a PR is a draft, it should go into "In Progress" - mark_as_in_progress: - name: 'Mark as In Progress' - needs: check_project - if: | - needs.check_project.outputs.is_in_project == '1' - && (github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'converted_to_draft') - && github.event.pull_request.draft == true - runs-on: ubuntu-latest - steps: - - name: Update status to in_progress - uses: github/update-project-action@f980378bc179626af5b4e20ec05ec39c7f7a6f6d - with: - github_token: ${{ secrets.GH_PROJECT_AUTOMATION }} - organization: getsentry - project_number: 31 - content_id: ${{ github.event.pull_request.node_id }} - field: Status - value: '🏗 In Progress' - - # When a PR is not a draft, it should go into "In Review" - mark_as_in_review: - name: 'Mark as In Review' - needs: check_project - if: | - needs.check_project.outputs.is_in_project == '1' - && (github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'ready_for_review') - && github.event.pull_request.draft == false - runs-on: ubuntu-latest - steps: - - name: Update status to in_review - id: update_status - uses: github/update-project-action@f980378bc179626af5b4e20ec05ec39c7f7a6f6d - with: - github_token: ${{ secrets.GH_PROJECT_AUTOMATION }} - organization: getsentry - project_number: 31 - content_id: ${{ github.event.pull_request.node_id }} - field: Status - value: '👀 In Review' - - # By default, closed PRs go into "Ready for Release" - # But if they are closed without merging, they should go into "Done" - mark_as_done: - name: 'Mark as Done' - needs: check_project - if: | - needs.check_project.outputs.is_in_project == '1' - && github.event.action == 'closed' && github.event.pull_request.merged == false - runs-on: ubuntu-latest - steps: - - name: Update status to done - id: update_status - uses: github/update-project-action@f980378bc179626af5b4e20ec05ec39c7f7a6f6d - with: - github_token: ${{ secrets.GH_PROJECT_AUTOMATION }} - organization: getsentry - project_number: 31 - content_id: ${{ github.event.pull_request.node_id }} - field: Status - value: '✅ Done' From 41e03724aff11f0d66d165adb4e0bb87114b90f1 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 5 Sep 2025 15:27:08 +0200 Subject: [PATCH 02/19] ref(browser): Add more specific `mechanism.type` to errors captured by `httpClientIntegration` (#17254) ref #17212 closes #17250 --- .../suites/integrations/httpclient/axios/test.ts | 2 +- .../suites/integrations/httpclient/fetch/simple/test.ts | 2 +- .../suites/integrations/httpclient/fetch/withRequest/test.ts | 2 +- .../httpclient/fetch/withRequestAndBodyAndOptions/test.ts | 2 +- .../httpclient/fetch/withRequestAndOptions/test.ts | 2 +- .../suites/integrations/httpclient/xhr/test.ts | 2 +- packages/browser/src/integrations/httpclient.ts | 5 ++++- 7 files changed, 10 insertions(+), 7 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/httpclient/axios/test.ts b/dev-packages/browser-integration-tests/suites/integrations/httpclient/axios/test.ts index 90d2e33f60e0..53eedfe06d8a 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/httpclient/axios/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/httpclient/axios/test.ts @@ -36,7 +36,7 @@ sentryTest( type: 'Error', value: 'HTTP Client Error with status code: 500', mechanism: { - type: 'http.client', + type: 'auto.http.client.xhr', handled: false, }, stacktrace: { diff --git a/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/simple/test.ts b/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/simple/test.ts index dcf7ed5ca8b1..562456a0a3a7 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/simple/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/simple/test.ts @@ -38,7 +38,7 @@ sentryTest( type: 'Error', value: 'HTTP Client Error with status code: 500', mechanism: { - type: 'http.client', + type: 'auto.http.client.fetch', handled: false, }, stacktrace: { diff --git a/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/withRequest/test.ts b/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/withRequest/test.ts index 46b886c49757..830181833c1d 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/withRequest/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/withRequest/test.ts @@ -34,7 +34,7 @@ sentryTest('works with a Request passed in', async ({ getLocalTestUrl, page }) = type: 'Error', value: 'HTTP Client Error with status code: 500', mechanism: { - type: 'http.client', + type: 'auto.http.client.fetch', handled: false, }, stacktrace: { diff --git a/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/withRequestAndBodyAndOptions/test.ts b/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/withRequestAndBodyAndOptions/test.ts index fdd6fd429b73..5f608c384c0a 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/withRequestAndBodyAndOptions/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/withRequestAndBodyAndOptions/test.ts @@ -36,7 +36,7 @@ sentryTest( type: 'Error', value: 'HTTP Client Error with status code: 500', mechanism: { - type: 'http.client', + type: 'auto.http.client.fetch', handled: false, }, stacktrace: { diff --git a/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/withRequestAndOptions/test.ts b/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/withRequestAndOptions/test.ts index d952ae2562ef..a87ffa735037 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/withRequestAndOptions/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/withRequestAndOptions/test.ts @@ -34,7 +34,7 @@ sentryTest('works with a Request (without body) & options passed in', async ({ g type: 'Error', value: 'HTTP Client Error with status code: 500', mechanism: { - type: 'http.client', + type: 'auto.http.client.fetch', handled: false, }, stacktrace: { diff --git a/dev-packages/browser-integration-tests/suites/integrations/httpclient/xhr/test.ts b/dev-packages/browser-integration-tests/suites/integrations/httpclient/xhr/test.ts index a0e11021ec67..99e65c8b37c8 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/httpclient/xhr/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/httpclient/xhr/test.ts @@ -36,7 +36,7 @@ sentryTest( type: 'Error', value: 'HTTP Client Error with status code: 500', mechanism: { - type: 'http.client', + type: 'auto.http.client.xhr', handled: false, }, stacktrace: { diff --git a/packages/browser/src/integrations/httpclient.ts b/packages/browser/src/integrations/httpclient.ts index 9aaa476b7618..76f32158f496 100644 --- a/packages/browser/src/integrations/httpclient.ts +++ b/packages/browser/src/integrations/httpclient.ts @@ -93,6 +93,7 @@ function _fetchResponseHandler( requestCookies, responseCookies, error, + type: 'fetch', }); captureEvent(event); @@ -165,6 +166,7 @@ function _xhrResponseHandler( responseHeaders, responseCookies, error, + type: 'xhr', }); captureEvent(event); @@ -362,6 +364,7 @@ function _createEvent(data: { url: string; method: string; status: number; + type: 'fetch' | 'xhr'; responseHeaders?: Record; responseCookies?: Record; requestHeaders?: Record; @@ -402,7 +405,7 @@ function _createEvent(data: { }; addExceptionMechanism(event, { - type: 'http.client', + type: `auto.http.client.${data.type}`, handled: false, }); From 1335cc9b9753cde3fc2df75b054b11b5c7219fd8 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 5 Sep 2025 15:51:48 +0200 Subject: [PATCH 03/19] ref(browser): Set more descriptive `mechanism.type` in `browserApiErrorsIntergation` (#17251) `type` now follows the[ trace origin](https://develop.sentry.dev/sdk/telemetry/traces/trace-origin/) naming scheme. Omitted `data.function` in favour of more specific types. see #17212 closes #17250 --- .../eventListener/event-target/test.ts | 3 +-- .../eventListener/named-function/test.ts | 3 +-- .../instrumentation/eventListener/remove/test.ts | 2 +- .../eventListener/thrown-error/test.ts | 3 +-- .../requestAnimationFrame/thrown-errors/test.ts | 2 +- .../instrumentation/setInterval/test.ts | 2 +- .../public-api/instrumentation/setTimeout/test.ts | 5 +---- .../instrumentation/setTimeoutFrozen/test.ts | 2 +- .../instrumentation/xhr/thrown-error/test.ts | 5 +---- .../ember-classic/tests/errors.test.ts | 4 ++-- .../ember-embroider/tests/errors.test.ts | 4 ++-- .../svelte-5/tests/errors.test.ts | 2 +- packages/browser/src/helpers.ts | 1 + .../browser/src/integrations/browserapierrors.ts | 15 +++++---------- .../integration/test/client/click-error.test.ts | 2 +- 15 files changed, 21 insertions(+), 34 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/public-api/instrumentation/eventListener/event-target/test.ts b/dev-packages/browser-integration-tests/suites/public-api/instrumentation/eventListener/event-target/test.ts index c2d0ed42d4f4..084379366c9a 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/instrumentation/eventListener/event-target/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/instrumentation/eventListener/event-target/test.ts @@ -13,10 +13,9 @@ sentryTest('should capture target name in mechanism data', async ({ getLocalTest type: 'Error', value: 'event_listener_error', mechanism: { - type: 'instrument', + type: 'auto.browser.browserapierrors.addEventListener', handled: false, data: { - function: 'addEventListener', handler: 'functionListener', target: 'EventTarget', }, diff --git a/dev-packages/browser-integration-tests/suites/public-api/instrumentation/eventListener/named-function/test.ts b/dev-packages/browser-integration-tests/suites/public-api/instrumentation/eventListener/named-function/test.ts index cee945ec8cdb..ba72afd447d3 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/instrumentation/eventListener/named-function/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/instrumentation/eventListener/named-function/test.ts @@ -13,10 +13,9 @@ sentryTest('should capture built-in handlers fn name in mechanism data', async ( type: 'Error', value: 'event_listener_error', mechanism: { - type: 'instrument', + type: 'auto.browser.browserapierrors.addEventListener', handled: false, data: { - function: 'addEventListener', handler: 'clickHandler', target: 'EventTarget', }, diff --git a/dev-packages/browser-integration-tests/suites/public-api/instrumentation/eventListener/remove/test.ts b/dev-packages/browser-integration-tests/suites/public-api/instrumentation/eventListener/remove/test.ts index cf643d796274..4d7a1b101e0d 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/instrumentation/eventListener/remove/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/instrumentation/eventListener/remove/test.ts @@ -13,7 +13,7 @@ sentryTest('should transparently remove event listeners from wrapped functions', type: 'Error', value: 'foo', mechanism: { - type: 'instrument', + type: 'auto.browser.browserapierrors.addEventListener', handled: false, }, stacktrace: { diff --git a/dev-packages/browser-integration-tests/suites/public-api/instrumentation/eventListener/thrown-error/test.ts b/dev-packages/browser-integration-tests/suites/public-api/instrumentation/eventListener/thrown-error/test.ts index 9a849fd22b88..c36f5fb73412 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/instrumentation/eventListener/thrown-error/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/instrumentation/eventListener/thrown-error/test.ts @@ -15,10 +15,9 @@ sentryTest( type: 'Error', value: 'event_listener_error', mechanism: { - type: 'instrument', + type: 'auto.browser.browserapierrors.addEventListener', handled: false, data: { - function: 'addEventListener', handler: '', target: 'EventTarget', }, diff --git a/dev-packages/browser-integration-tests/suites/public-api/instrumentation/requestAnimationFrame/thrown-errors/test.ts b/dev-packages/browser-integration-tests/suites/public-api/instrumentation/requestAnimationFrame/thrown-errors/test.ts index 02b8771d1785..30565ea48a20 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/instrumentation/requestAnimationFrame/thrown-errors/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/instrumentation/requestAnimationFrame/thrown-errors/test.ts @@ -13,7 +13,7 @@ sentryTest('should capture exceptions inside callback', async ({ getLocalTestUrl type: 'Error', value: 'requestAnimationFrame_error', mechanism: { - type: 'instrument', + type: 'auto.browser.browserapierrors.requestAnimationFrame', handled: false, }, stacktrace: { diff --git a/dev-packages/browser-integration-tests/suites/public-api/instrumentation/setInterval/test.ts b/dev-packages/browser-integration-tests/suites/public-api/instrumentation/setInterval/test.ts index 02942b276a3a..9be24f4e4e57 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/instrumentation/setInterval/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/instrumentation/setInterval/test.ts @@ -13,7 +13,7 @@ sentryTest('Instrumentation should capture errors in setInterval', async ({ getL type: 'Error', value: 'setInterval_error', mechanism: { - type: 'instrument', + type: 'auto.browser.browserapierrors.setInterval', handled: false, }, stacktrace: { diff --git a/dev-packages/browser-integration-tests/suites/public-api/instrumentation/setTimeout/test.ts b/dev-packages/browser-integration-tests/suites/public-api/instrumentation/setTimeout/test.ts index 321a96d7b393..3045f633f8c0 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/instrumentation/setTimeout/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/instrumentation/setTimeout/test.ts @@ -13,11 +13,8 @@ sentryTest('Instrumentation should capture errors in setTimeout', async ({ getLo type: 'Error', value: 'setTimeout_error', mechanism: { - type: 'instrument', + type: 'auto.browser.browserapierrors.setTimeout', handled: false, - data: { - function: 'setTimeout', - }, }, stacktrace: { frames: expect.any(Array), diff --git a/dev-packages/browser-integration-tests/suites/public-api/instrumentation/setTimeoutFrozen/test.ts b/dev-packages/browser-integration-tests/suites/public-api/instrumentation/setTimeoutFrozen/test.ts index c4322884cfe6..4c6f9280aee0 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/instrumentation/setTimeoutFrozen/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/instrumentation/setTimeoutFrozen/test.ts @@ -25,7 +25,7 @@ sentryTest( type: 'Error', value: 'setTimeout_error', mechanism: { - type: 'instrument', + type: 'auto.browser.browserapierrors.setTimeout', handled: false, }, stacktrace: { diff --git a/dev-packages/browser-integration-tests/suites/public-api/instrumentation/xhr/thrown-error/test.ts b/dev-packages/browser-integration-tests/suites/public-api/instrumentation/xhr/thrown-error/test.ts index 330c94761854..e91081a71f97 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/instrumentation/xhr/thrown-error/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/instrumentation/xhr/thrown-error/test.ts @@ -15,11 +15,8 @@ sentryTest( type: 'Error', value: 'xhr_error', mechanism: { - type: 'instrument', + type: 'auto.browser.browserapierrors.xhr.onreadystatechange', handled: false, - data: { - function: 'onreadystatechange', - }, }, stacktrace: { frames: expect.any(Array), diff --git a/dev-packages/e2e-tests/test-applications/ember-classic/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/ember-classic/tests/errors.test.ts index 2e836cf8b756..3f4b1f5c099e 100644 --- a/dev-packages/e2e-tests/test-applications/ember-classic/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/ember-classic/tests/errors.test.ts @@ -19,7 +19,7 @@ test('sends an error', async ({ page }) => { type: 'TypeError', value: 'this.nonExistentFunction is not a function', mechanism: { - type: 'instrument', + type: 'auto.browser.browserapierrors.addEventListener', handled: false, }, }, @@ -55,7 +55,7 @@ test('assigns the correct transaction value after a navigation', async ({ page } type: 'TypeError', value: 'this.nonExistentFunction is not a function', mechanism: { - type: 'instrument', + type: 'auto.browser.browserapierrors.addEventListener', handled: false, }, }, diff --git a/dev-packages/e2e-tests/test-applications/ember-embroider/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/ember-embroider/tests/errors.test.ts index 9171611e42cb..e9cfd7e9f1c6 100644 --- a/dev-packages/e2e-tests/test-applications/ember-embroider/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/ember-embroider/tests/errors.test.ts @@ -19,7 +19,7 @@ test('sends an error', async ({ page }) => { type: 'TypeError', value: 'this.nonExistentFunction is not a function', mechanism: { - type: 'instrument', + type: 'auto.browser.browserapierrors.addEventListener', handled: false, }, }, @@ -55,7 +55,7 @@ test('assigns the correct transaction value after a navigation', async ({ page } type: 'TypeError', value: 'this.nonExistentFunction is not a function', mechanism: { - type: 'instrument', + type: 'auto.browser.browserapierrors.addEventListener', handled: false, }, }, diff --git a/dev-packages/e2e-tests/test-applications/svelte-5/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/svelte-5/tests/errors.test.ts index 6e3267eab2ed..d536aa0ab928 100644 --- a/dev-packages/e2e-tests/test-applications/svelte-5/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/svelte-5/tests/errors.test.ts @@ -19,7 +19,7 @@ test('sends an error', async ({ page }) => { type: 'Error', value: 'Error thrown from Svelte 5 E2E test app', mechanism: { - type: 'instrument', + type: 'auto.browser.browserapierrors.addEventListener', handled: false, }, }, diff --git a/packages/browser/src/helpers.ts b/packages/browser/src/helpers.ts index 362020a8d845..93c87e1d6161 100644 --- a/packages/browser/src/helpers.ts +++ b/packages/browser/src/helpers.ts @@ -133,6 +133,7 @@ export function wrap( return event; }); + // no need to add a mechanism here, we already add it via an event processor above captureException(ex); }); diff --git a/packages/browser/src/integrations/browserapierrors.ts b/packages/browser/src/integrations/browserapierrors.ts index 6db6d40c67c2..7e94c2bc7167 100644 --- a/packages/browser/src/integrations/browserapierrors.ts +++ b/packages/browser/src/integrations/browserapierrors.ts @@ -108,9 +108,8 @@ function _wrapTimeFunction(original: () => void): () => number { const originalCallback = args[0]; args[0] = wrap(originalCallback, { mechanism: { - data: { function: getFunctionName(original) }, handled: false, - type: 'instrument', + type: `auto.browser.browserapierrors.${getFunctionName(original)}`, }, }); return original.apply(this, args); @@ -123,11 +122,10 @@ function _wrapRAF(original: () => void): (callback: () => void) => unknown { wrap(callback, { mechanism: { data: { - function: 'requestAnimationFrame', handler: getFunctionName(original), }, handled: false, - type: 'instrument', + type: 'auto.browser.browserapierrors.requestAnimationFrame', }, }), ]); @@ -146,11 +144,10 @@ function _wrapXHR(originalSend: () => void): () => void { const wrapOptions = { mechanism: { data: { - function: prop, handler: getFunctionName(original), }, handled: false, - type: 'instrument', + type: `auto.browser.browserapierrors.xhr.${prop}`, }, }; @@ -194,12 +191,11 @@ function _wrapEventTarget(target: string, integrationOptions: BrowserApiErrorsOp fn.handleEvent = wrap(fn.handleEvent, { mechanism: { data: { - function: 'handleEvent', handler: getFunctionName(fn), target, }, handled: false, - type: 'instrument', + type: 'auto.browser.browserapierrors.handleEvent', }, }); } @@ -216,12 +212,11 @@ function _wrapEventTarget(target: string, integrationOptions: BrowserApiErrorsOp wrap(fn, { mechanism: { data: { - function: 'addEventListener', handler: getFunctionName(fn), target, }, handled: false, - type: 'instrument', + type: 'auto.browser.browserapierrors.addEventListener', }, }), options, diff --git a/packages/remix/test/integration/test/client/click-error.test.ts b/packages/remix/test/integration/test/client/click-error.test.ts index c8c70105708f..78573bb9782a 100644 --- a/packages/remix/test/integration/test/client/click-error.test.ts +++ b/packages/remix/test/integration/test/client/click-error.test.ts @@ -20,7 +20,7 @@ test('should report a manually captured message on click with the correct stackt type: 'Error', value: 'ClickError', stacktrace: { frames: expect.any(Array) }, - mechanism: { type: 'instrument', handled: false }, + mechanism: { type: 'auto.browser.browserapierrors.addEventListener', handled: false }, }, ]); From d954060b368167ea48e72042cd80abcce32ebfa8 Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Fri, 5 Sep 2025 16:51:52 +0200 Subject: [PATCH 04/19] chore(test): Remove `geist` font (#17541) This should fix the `enforce-license-compliance` CI check --- .../e2e-tests/test-applications/nextjs-t3/package.json | 1 - .../test-applications/nextjs-t3/src/app/layout.tsx | 3 +-- .../test-applications/nextjs-t3/tailwind.config.ts | 8 -------- 3 files changed, 1 insertion(+), 11 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/package.json b/dev-packages/e2e-tests/test-applications/nextjs-t3/package.json index 467d47c69c68..af4d92497ad9 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-t3/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/package.json @@ -20,7 +20,6 @@ "@trpc/client": "~11.3.0", "@trpc/react-query": "~11.3.0", "@trpc/server": "~11.3.0", - "geist": "^1.3.0", "next": "14.2.29", "react": "18.3.1", "react-dom": "18.3.1", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/src/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/app/layout.tsx index e703260be1a3..3d32967aa290 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-t3/src/app/layout.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/app/layout.tsx @@ -1,6 +1,5 @@ import '~/styles/globals.css'; -import { GeistSans } from 'geist/font/sans'; import { type Metadata } from 'next'; import { TRPCReactProvider } from '~/trpc/react'; @@ -13,7 +12,7 @@ export const metadata: Metadata = { export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { return ( - + {children} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/tailwind.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-t3/tailwind.config.ts index 80a667d155d8..9739341e9b52 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-t3/tailwind.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/tailwind.config.ts @@ -1,14 +1,6 @@ import { type Config } from 'tailwindcss'; -import { fontFamily } from 'tailwindcss/defaultTheme'; export default { content: ['./src/**/*.tsx'], - theme: { - extend: { - fontFamily: { - sans: ['var(--font-geist-sans)', ...fontFamily.sans], - }, - }, - }, plugins: [], } satisfies Config; From 8e0ad6053dff6bd66f34cdb8a854a63f25a4db41 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 5 Sep 2025 16:52:32 +0200 Subject: [PATCH 05/19] test(node-integration-tests): pin ai@5.0.30 to fix test fails (#17542) Something 5.0.31 causes weird errors around `msw` not being found. Potentially related: https://github.com/vercel/ai/issues/8469 --- .../suites/tracing/vercelai/v5/test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts index 470080658dfa..2b697bfedbdb 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts @@ -406,7 +406,7 @@ describe('Vercel AI integration (V5)', () => { }, { additionalDependencies: { - ai: '^5.0.0', + ai: '5.0.30', }, }, ); @@ -422,7 +422,7 @@ describe('Vercel AI integration (V5)', () => { }, { additionalDependencies: { - ai: '^5.0.0', + ai: '5.0.30', }, }, ); @@ -541,7 +541,7 @@ describe('Vercel AI integration (V5)', () => { }, { additionalDependencies: { - ai: '^5.0.0', + ai: '5.0.30', }, }, ); @@ -557,7 +557,7 @@ describe('Vercel AI integration (V5)', () => { }, { additionalDependencies: { - ai: '^5.0.0', + ai: '5.0.30', }, }, ); From af69b45241ff6283ef6d00c28226fa7229ea129e Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 5 Sep 2025 16:55:16 +0200 Subject: [PATCH 06/19] ref(nestjs): Add `mechanism` to captured errors (#17312) The NestJS SDK didn't add a mechanism to a bunch of caught errors. This resulted in the errors being incorrectly marked as `handled: true` and the `mechansim.type` defaulting to `'generic'`. This patch: - adds the mechanism to all `captureException` calls within the SDK, marking caught exceptions as `handled: false` - reviewers: Please let me know if any of these should in fact be `handled: true`! - adds a specific `mechanism.type`, following the naming scheme of [trace origin](https://develop.sentry.dev/sdk/telemetry/traces/trace-origin/) - adjusts and adds test assertions so that we actually test on the expected mechanism --- .../nestjs-11/tests/cron-decorator.test.ts | 5 ++++ .../nestjs-11/tests/errors.test.ts | 4 +++ .../nestjs-8/tests/errors.test.ts | 4 +++ .../tests/errors.test.ts | 10 +++++++ .../nestjs-basic/tests/cron-decorator.test.ts | 10 +++++++ .../nestjs-basic/tests/errors.test.ts | 4 +++ .../tests/events.test.ts | 5 +++- .../tests/cron-decorator.test.ts | 4 +++ .../nestjs-fastify/tests/errors.test.ts | 5 ++++ .../nestjs-graphql/tests/errors.test.ts | 5 ++++ .../tests/errors.test.ts | 15 ++++++++++ .../tests/errors.test.ts | 15 ++++++++++ packages/nestjs/src/decorators.ts | 6 ++-- .../sentry-nest-event-instrumentation.ts | 7 ++++- packages/nestjs/src/setup.ts | 28 ++++++++++++++++--- packages/nestjs/test/decorators.test.ts | 7 ++++- .../nestjs/test/integrations/nest.test.ts | 7 ++++- .../nestjs/test/sentry-global-filter.test.ts | 7 ++++- 18 files changed, 136 insertions(+), 12 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/tests/cron-decorator.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/tests/cron-decorator.test.ts index e6ac7ae855ae..bf5e29004066 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-11/tests/cron-decorator.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-11/tests/cron-decorator.test.ts @@ -71,6 +71,11 @@ test('Sends exceptions to Sentry on error in cron job', async ({ baseURL }) => { expect(errorEvent.exception?.values).toHaveLength(1); expect(errorEvent.exception?.values?.[0]?.value).toBe('Test error from cron job'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.cron.nestjs.async', + }); + expect(errorEvent.contexts?.trace).toEqual({ trace_id: expect.stringMatching(/[a-f0-9]{32}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/tests/errors.test.ts index a24d1010eca4..e4b4423e8719 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-11/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-11/tests/errors.test.ts @@ -13,6 +13,10 @@ test('Sends exception to Sentry', async ({ baseURL }) => { expect(errorEvent.exception?.values).toHaveLength(1); expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception with id 123'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.http.nestjs.global_filter', + }); expect(errorEvent.request).toEqual({ method: 'GET', diff --git a/dev-packages/e2e-tests/test-applications/nestjs-8/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-8/tests/errors.test.ts index e60e94691210..4b05da9c4d98 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-8/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-8/tests/errors.test.ts @@ -13,6 +13,10 @@ test('Sends exception to Sentry', async ({ baseURL }) => { expect(errorEvent.exception?.values).toHaveLength(1); expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception with id 123'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.http.nestjs.global_filter', + }); expect(errorEvent.request).toEqual({ method: 'GET', diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/tests/errors.test.ts index 62103cfafbd9..3c9d8532c889 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/tests/errors.test.ts @@ -14,6 +14,11 @@ test('Sends exception to Sentry', async ({ baseURL }) => { expect(errorEvent.exception?.values).toHaveLength(1); expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception with id 123'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.http.nestjs.global_filter', + }); + expect(errorEvent.request).toEqual({ method: 'GET', cookies: {}, @@ -102,6 +107,11 @@ test('Sends graphql exception to Sentry', async ({ baseURL }) => { expect(errorEvent.exception?.values).toHaveLength(1); expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception!'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.graphql.nestjs.global_filter', + }); + expect(errorEvent.request).toEqual({ method: 'POST', cookies: {}, diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/cron-decorator.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/cron-decorator.test.ts index c193a94911c1..e0610f36c676 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/cron-decorator.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/cron-decorator.test.ts @@ -75,6 +75,11 @@ test('Sends exceptions to Sentry on error in async cron job', async ({ baseURL } span_id: expect.stringMatching(/[a-f0-9]{16}/), }); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.cron.nestjs.async', + }); + // kill cron so tests don't get stuck await fetch(`${baseURL}/kill-test-cron/test-async-cron-error`); }); @@ -92,6 +97,11 @@ test('Sends exceptions to Sentry on error in sync cron job', async ({ baseURL }) span_id: expect.stringMatching(/[a-f0-9]{16}/), }); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.cron.nestjs', + }); + // kill cron so tests don't get stuck await fetch(`${baseURL}/kill-test-cron/test-sync-cron-error`); }); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/errors.test.ts index 748730985cf6..09effba34198 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/errors.test.ts @@ -13,6 +13,10 @@ test('Sends exception to Sentry', async ({ baseURL }) => { expect(errorEvent.exception?.values).toHaveLength(1); expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception with id 123'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.http.nestjs.global_filter', + }); expect(errorEvent.request).toEqual({ method: 'GET', diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts index 62781e32e37c..60c1ad6590af 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts @@ -21,7 +21,10 @@ test('Event emitter', async () => { type: 'Error', value: 'Test error from event handler', stacktrace: expect.any(Object), - mechanism: expect.any(Object), + mechanism: { + handled: false, + type: 'auto.event.nestjs', + }, }, ], }); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/cron-decorator.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/cron-decorator.test.ts index e352e8fdba8f..1e9d62c2c96a 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/cron-decorator.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/cron-decorator.test.ts @@ -71,6 +71,10 @@ test('Sends exceptions to Sentry on error in cron job', async ({ baseURL }) => { expect(errorEvent.exception?.values).toHaveLength(1); expect(errorEvent.exception?.values?.[0]?.value).toBe('Test error from cron job'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.cron.nestjs.async', + }); expect(errorEvent.contexts?.trace).toEqual({ trace_id: expect.stringMatching(/[a-f0-9]{32}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/errors.test.ts index 4eea05edd36f..1cbddb8256a3 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/errors.test.ts @@ -14,6 +14,11 @@ test('Sends exception to Sentry', async ({ baseURL }) => { expect(errorEvent.exception?.values).toHaveLength(1); expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception with id 123'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.http.nestjs.global_filter', + }); + expect(errorEvent.request).toEqual({ method: 'GET', cookies: {}, diff --git a/dev-packages/e2e-tests/test-applications/nestjs-graphql/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-graphql/tests/errors.test.ts index 9f9e7cdc0d17..6b0047366203 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-graphql/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-graphql/tests/errors.test.ts @@ -32,6 +32,11 @@ test('Sends exception to Sentry', async ({ baseURL }) => { expect(errorEvent.exception?.values).toHaveLength(1); expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception!'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.graphql.nestjs.global_filter', + }); + expect(errorEvent.request).toEqual({ method: 'POST', cookies: {}, diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules-decorator/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules-decorator/tests/errors.test.ts index fc7520fbcb7b..4e82d8f1d913 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules-decorator/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules-decorator/tests/errors.test.ts @@ -22,6 +22,11 @@ test('Sends unexpected exception to Sentry if thrown in module with global filte expect(errorEvent.exception?.values).toHaveLength(1); expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an uncaught exception!'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.function.nestjs.exception_captured', + }); + expect(errorEvent.request).toEqual({ method: 'GET', cookies: {}, @@ -59,6 +64,11 @@ test('Sends unexpected exception to Sentry if thrown in module with local filter expect(errorEvent.exception?.values).toHaveLength(1); expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an uncaught exception!'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.function.nestjs.exception_captured', + }); + expect(errorEvent.request).toEqual({ method: 'GET', cookies: {}, @@ -98,6 +108,11 @@ test('Sends unexpected exception to Sentry if thrown in module that was register expect(errorEvent.exception?.values).toHaveLength(1); expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an uncaught exception!'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.function.nestjs.exception_captured', + }); + expect(errorEvent.request).toEqual({ method: 'GET', cookies: {}, diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/errors.test.ts index e29d4677397f..eb3a3d809417 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/errors.test.ts @@ -14,6 +14,11 @@ test('Sends unexpected exception to Sentry if thrown in module with global filte expect(errorEvent.exception?.values).toHaveLength(1); expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an uncaught exception!'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.http.nestjs.global_filter', + }); + expect(errorEvent.request).toEqual({ method: 'GET', cookies: {}, @@ -43,6 +48,11 @@ test('Sends unexpected exception to Sentry if thrown in module with local filter expect(errorEvent.exception?.values).toHaveLength(1); expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an uncaught exception!'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.http.nestjs.global_filter', + }); + expect(errorEvent.request).toEqual({ method: 'GET', cookies: {}, @@ -74,6 +84,11 @@ test('Sends unexpected exception to Sentry if thrown in module that was register expect(errorEvent.exception?.values).toHaveLength(1); expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an uncaught exception!'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.http.nestjs.global_filter', + }); + expect(errorEvent.request).toEqual({ method: 'GET', cookies: {}, diff --git a/packages/nestjs/src/decorators.ts b/packages/nestjs/src/decorators.ts index 449c884b1008..8f1a7151894f 100644 --- a/packages/nestjs/src/decorators.ts +++ b/packages/nestjs/src/decorators.ts @@ -24,12 +24,12 @@ export const SentryCron = (monitorSlug: string, monitorConfig?: MonitorConfig): try { result = originalMethod.apply(this, args); } catch (e) { - captureException(e); + captureException(e, { mechanism: { handled: false, type: 'auto.cron.nestjs' } }); throw e; } if (isThenable(result)) { return result.then(undefined, e => { - captureException(e); + captureException(e, { mechanism: { handled: false, type: 'auto.cron.nestjs.async' } }); throw e; }); } @@ -86,7 +86,7 @@ export function SentryExceptionCaptured() { return originalCatch.apply(this, [exception, host, ...args]); } - captureException(exception); + captureException(exception, { mechanism: { handled: false, type: 'auto.function.nestjs.exception_captured' } }); return originalCatch.apply(this, [exception, host, ...args]); }; diff --git a/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts index a572bb93a52f..f8076087fd5d 100644 --- a/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts +++ b/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts @@ -109,7 +109,12 @@ export class SentryNestEventInstrumentation extends InstrumentationBase { return result; } catch (error) { // exceptions from event handlers are not caught by global error filter - captureException(error); + captureException(error, { + mechanism: { + handled: false, + type: 'auto.event.nestjs', + }, + }); throw error; } }); diff --git a/packages/nestjs/src/setup.ts b/packages/nestjs/src/setup.ts index c1af5938e3c2..cc6d65514b77 100644 --- a/packages/nestjs/src/setup.ts +++ b/packages/nestjs/src/setup.ts @@ -101,7 +101,12 @@ class SentryGlobalFilter extends BaseExceptionFilter { this._logger.error(exception.message, exception.stack); } - captureException(exception); + captureException(exception, { + mechanism: { + handled: false, + type: 'auto.graphql.nestjs.global_filter', + }, + }); throw exception; } @@ -117,7 +122,12 @@ class SentryGlobalFilter extends BaseExceptionFilter { // Handle any other kind of error if (!(exception instanceof Error)) { if (!isExpectedError(exception)) { - captureException(exception); + captureException(exception, { + mechanism: { + handled: false, + type: 'auto.rpc.nestjs.global_filter', + }, + }); } throw exception; } @@ -125,7 +135,12 @@ class SentryGlobalFilter extends BaseExceptionFilter { // In this case we're likely running into an RpcException, which the user should handle with a dedicated filter // https://github.com/nestjs/nest/blob/master/sample/03-microservices/src/common/filters/rpc-exception.filter.ts if (!isExpectedError(exception)) { - captureException(exception); + captureException(exception, { + mechanism: { + handled: false, + type: 'auto.rpc.nestjs.global_filter', + }, + }); } this._logger.warn( @@ -139,7 +154,12 @@ class SentryGlobalFilter extends BaseExceptionFilter { // HTTP exceptions if (!isExpectedError(exception)) { - captureException(exception); + captureException(exception, { + mechanism: { + handled: false, + type: 'auto.http.nestjs.global_filter', + }, + }); } return super.catch(exception, host); diff --git a/packages/nestjs/test/decorators.test.ts b/packages/nestjs/test/decorators.test.ts index 07bd8922f373..b5d17451a9f2 100644 --- a/packages/nestjs/test/decorators.test.ts +++ b/packages/nestjs/test/decorators.test.ts @@ -288,7 +288,12 @@ describe('SentryExceptionCaptured decorator', () => { decoratedMethod(exception, host); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith(exception); + expect(captureExceptionSpy).toHaveBeenCalledWith(exception, { + mechanism: { + handled: false, + type: 'auto.function.nestjs.exception_captured', + }, + }); expect(originalCatch).toHaveBeenCalledWith(exception, host); isExpectedErrorSpy.mockRestore(); diff --git a/packages/nestjs/test/integrations/nest.test.ts b/packages/nestjs/test/integrations/nest.test.ts index 2eecfbc6b240..69fb022441dd 100644 --- a/packages/nestjs/test/integrations/nest.test.ts +++ b/packages/nestjs/test/integrations/nest.test.ts @@ -97,7 +97,12 @@ describe('Nest', () => { decorated(mockTarget, 'testMethod', descriptor); await expect(descriptor.value()).rejects.toThrow(error); - expect(core.captureException).toHaveBeenCalledWith(error); + expect(core.captureException).toHaveBeenCalledWith(error, { + mechanism: { + handled: false, + type: 'auto.event.nestjs', + }, + }); }); it('should skip wrapping for internal Sentry handlers', () => { diff --git a/packages/nestjs/test/sentry-global-filter.test.ts b/packages/nestjs/test/sentry-global-filter.test.ts index fc8a3444aecd..d9b4ff3d1b1f 100644 --- a/packages/nestjs/test/sentry-global-filter.test.ts +++ b/packages/nestjs/test/sentry-global-filter.test.ts @@ -133,7 +133,12 @@ describe('SentryGlobalFilter', () => { filter.catch(error, mockArgumentsHost); }).toThrow(error); - expect(mockCaptureException).toHaveBeenCalledWith(error); + expect(mockCaptureException).toHaveBeenCalledWith(error, { + mechanism: { + handled: false, + type: 'auto.graphql.nestjs.global_filter', + }, + }); expect(mockLoggerError).toHaveBeenCalledWith(error.message, error.stack); }); }); From 65e549c9a35651752db984c8d0d3b06d9efec9b8 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 5 Sep 2025 17:44:22 +0200 Subject: [PATCH 07/19] ref(core): Add more specific event `mechanism`s and span origins to `openAiIntegration` (#17288) Also changes the `sentry.origin` attribute to from `auto.function.openai` to `auto.ai.openai` since `ai` is the widely used category for these spans. (this was added initially via https://github.com/getsentry/sentry-javascript/pull/17288) `mechanism.type` now follows the same pattern trace origin pattern. ref #17212 ref #17252 --- .../suites/tracing/openai/test.ts | 3 +- .../tracing/openai/openai-tool-calls/test.ts | 32 ++++++------ .../suites/tracing/openai/test.ts | 52 +++++++++---------- packages/core/src/utils/openai/index.ts | 16 +++++- packages/core/src/utils/openai/streaming.ts | 1 + 5 files changed, 59 insertions(+), 45 deletions(-) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts index fc38fc6339b2..c1aee24136a4 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts @@ -19,6 +19,7 @@ it('traces a basic chat completion request', async () => { data: expect.objectContaining({ 'gen_ai.operation.name': 'chat', 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-3.5-turbo', 'gen_ai.request.temperature': 0.7, @@ -31,7 +32,7 @@ it('traces a basic chat completion request', async () => { }), description: 'chat gpt-3.5-turbo', op: 'gen_ai.chat', - origin: 'auto.function.openai', + origin: 'auto.ai.openai', }), ]), ); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts index a03181d5625b..98dab6e77b86 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts @@ -65,7 +65,7 @@ describe('OpenAI Tool Calls integration', () => { data: { 'gen_ai.operation.name': 'chat', 'sentry.op': 'gen_ai.chat', - 'sentry.origin': 'auto.function.openai', + 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.available_tools': WEATHER_TOOL_DEFINITION, @@ -83,7 +83,7 @@ describe('OpenAI Tool Calls integration', () => { }, description: 'chat gpt-4', op: 'gen_ai.chat', - origin: 'auto.function.openai', + origin: 'auto.ai.openai', status: 'ok', }), // Second span - chat completion with tools and streaming @@ -91,7 +91,7 @@ describe('OpenAI Tool Calls integration', () => { data: { 'gen_ai.operation.name': 'chat', 'sentry.op': 'gen_ai.chat', - 'sentry.origin': 'auto.function.openai', + 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.stream': true, @@ -111,7 +111,7 @@ describe('OpenAI Tool Calls integration', () => { }, description: 'chat gpt-4 stream-response', op: 'gen_ai.chat', - origin: 'auto.function.openai', + origin: 'auto.ai.openai', status: 'ok', }), // Third span - responses API with tools (non-streaming) @@ -119,7 +119,7 @@ describe('OpenAI Tool Calls integration', () => { data: { 'gen_ai.operation.name': 'responses', 'sentry.op': 'gen_ai.responses', - 'sentry.origin': 'auto.function.openai', + 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.available_tools': WEATHER_TOOL_DEFINITION, @@ -137,7 +137,7 @@ describe('OpenAI Tool Calls integration', () => { }, description: 'responses gpt-4', op: 'gen_ai.responses', - origin: 'auto.function.openai', + origin: 'auto.ai.openai', status: 'ok', }), // Fourth span - responses API with tools and streaming @@ -145,7 +145,7 @@ describe('OpenAI Tool Calls integration', () => { data: { 'gen_ai.operation.name': 'responses', 'sentry.op': 'gen_ai.responses', - 'sentry.origin': 'auto.function.openai', + 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.stream': true, @@ -165,7 +165,7 @@ describe('OpenAI Tool Calls integration', () => { }, description: 'responses gpt-4 stream-response', op: 'gen_ai.responses', - origin: 'auto.function.openai', + origin: 'auto.ai.openai', status: 'ok', }), ]), @@ -179,7 +179,7 @@ describe('OpenAI Tool Calls integration', () => { data: { 'gen_ai.operation.name': 'chat', 'sentry.op': 'gen_ai.chat', - 'sentry.origin': 'auto.function.openai', + 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather like in Paris today?"}]', @@ -200,7 +200,7 @@ describe('OpenAI Tool Calls integration', () => { }, description: 'chat gpt-4', op: 'gen_ai.chat', - origin: 'auto.function.openai', + origin: 'auto.ai.openai', status: 'ok', }), // Second span - chat completion with tools and streaming with PII @@ -208,7 +208,7 @@ describe('OpenAI Tool Calls integration', () => { data: { 'gen_ai.operation.name': 'chat', 'sentry.op': 'gen_ai.chat', - 'sentry.origin': 'auto.function.openai', + 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.stream': true, @@ -230,7 +230,7 @@ describe('OpenAI Tool Calls integration', () => { }, description: 'chat gpt-4 stream-response', op: 'gen_ai.chat', - origin: 'auto.function.openai', + origin: 'auto.ai.openai', status: 'ok', }), // Third span - responses API with tools (non-streaming) with PII @@ -238,7 +238,7 @@ describe('OpenAI Tool Calls integration', () => { data: { 'gen_ai.operation.name': 'responses', 'sentry.op': 'gen_ai.responses', - 'sentry.origin': 'auto.function.openai', + 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather like in Paris today?"}]', @@ -258,7 +258,7 @@ describe('OpenAI Tool Calls integration', () => { }, description: 'responses gpt-4', op: 'gen_ai.responses', - origin: 'auto.function.openai', + origin: 'auto.ai.openai', status: 'ok', }), // Fourth span - responses API with tools and streaming with PII @@ -266,7 +266,7 @@ describe('OpenAI Tool Calls integration', () => { data: { 'gen_ai.operation.name': 'responses', 'sentry.op': 'gen_ai.responses', - 'sentry.origin': 'auto.function.openai', + 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.stream': true, @@ -288,7 +288,7 @@ describe('OpenAI Tool Calls integration', () => { }, description: 'responses gpt-4 stream-response', op: 'gen_ai.responses', - origin: 'auto.function.openai', + origin: 'auto.ai.openai', status: 'ok', }), ]), diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts index 967cf55bb130..c0c0b79e95f7 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -14,7 +14,7 @@ describe('OpenAI integration', () => { data: { 'gen_ai.operation.name': 'chat', 'sentry.op': 'gen_ai.chat', - 'sentry.origin': 'auto.function.openai', + 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-3.5-turbo', 'gen_ai.request.temperature': 0.7, @@ -32,7 +32,7 @@ describe('OpenAI integration', () => { }, description: 'chat gpt-3.5-turbo', op: 'gen_ai.chat', - origin: 'auto.function.openai', + origin: 'auto.ai.openai', status: 'ok', }), // Second span - responses API @@ -40,7 +40,7 @@ describe('OpenAI integration', () => { data: { 'gen_ai.operation.name': 'responses', 'sentry.op': 'gen_ai.responses', - 'sentry.origin': 'auto.function.openai', + 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-3.5-turbo', 'gen_ai.response.model': 'gpt-3.5-turbo', @@ -57,7 +57,7 @@ describe('OpenAI integration', () => { }, description: 'responses gpt-3.5-turbo', op: 'gen_ai.responses', - origin: 'auto.function.openai', + origin: 'auto.ai.openai', status: 'ok', }), // Third span - error handling @@ -65,13 +65,13 @@ describe('OpenAI integration', () => { data: { 'gen_ai.operation.name': 'chat', 'sentry.op': 'gen_ai.chat', - 'sentry.origin': 'auto.function.openai', + 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'error-model', }, description: 'chat error-model', op: 'gen_ai.chat', - origin: 'auto.function.openai', + origin: 'auto.ai.openai', status: 'unknown_error', }), // Fourth span - chat completions streaming @@ -79,7 +79,7 @@ describe('OpenAI integration', () => { data: { 'gen_ai.operation.name': 'chat', 'sentry.op': 'gen_ai.chat', - 'sentry.origin': 'auto.function.openai', + 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.temperature': 0.8, @@ -99,7 +99,7 @@ describe('OpenAI integration', () => { }, description: 'chat gpt-4 stream-response', op: 'gen_ai.chat', - origin: 'auto.function.openai', + origin: 'auto.ai.openai', status: 'ok', }), // Fifth span - responses API streaming @@ -107,7 +107,7 @@ describe('OpenAI integration', () => { data: { 'gen_ai.operation.name': 'responses', 'sentry.op': 'gen_ai.responses', - 'sentry.origin': 'auto.function.openai', + 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.stream': true, @@ -126,7 +126,7 @@ describe('OpenAI integration', () => { }, description: 'responses gpt-4 stream-response', op: 'gen_ai.responses', - origin: 'auto.function.openai', + origin: 'auto.ai.openai', status: 'ok', }), // Sixth span - error handling in streaming context @@ -137,11 +137,11 @@ describe('OpenAI integration', () => { 'gen_ai.request.stream': true, 'gen_ai.system': 'openai', 'sentry.op': 'gen_ai.chat', - 'sentry.origin': 'auto.function.openai', + 'sentry.origin': 'auto.ai.openai', }, description: 'chat error-model stream-response', op: 'gen_ai.chat', - origin: 'auto.function.openai', + origin: 'auto.ai.openai', status: 'internal_error', }), ]), @@ -155,7 +155,7 @@ describe('OpenAI integration', () => { data: { 'gen_ai.operation.name': 'chat', 'sentry.op': 'gen_ai.chat', - 'sentry.origin': 'auto.function.openai', + 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-3.5-turbo', 'gen_ai.request.temperature': 0.7, @@ -176,7 +176,7 @@ describe('OpenAI integration', () => { }, description: 'chat gpt-3.5-turbo', op: 'gen_ai.chat', - origin: 'auto.function.openai', + origin: 'auto.ai.openai', status: 'ok', }), // Second span - responses API with PII @@ -184,7 +184,7 @@ describe('OpenAI integration', () => { data: { 'gen_ai.operation.name': 'responses', 'sentry.op': 'gen_ai.responses', - 'sentry.origin': 'auto.function.openai', + 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-3.5-turbo', 'gen_ai.request.messages': '"Translate this to French: Hello"', @@ -203,7 +203,7 @@ describe('OpenAI integration', () => { }, description: 'responses gpt-3.5-turbo', op: 'gen_ai.responses', - origin: 'auto.function.openai', + origin: 'auto.ai.openai', status: 'ok', }), // Third span - error handling with PII @@ -211,14 +211,14 @@ describe('OpenAI integration', () => { data: { 'gen_ai.operation.name': 'chat', 'sentry.op': 'gen_ai.chat', - 'sentry.origin': 'auto.function.openai', + 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'error-model', 'gen_ai.request.messages': '[{"role":"user","content":"This will fail"}]', }, description: 'chat error-model', op: 'gen_ai.chat', - origin: 'auto.function.openai', + origin: 'auto.ai.openai', status: 'unknown_error', }), // Fourth span - chat completions streaming with PII @@ -226,7 +226,7 @@ describe('OpenAI integration', () => { data: expect.objectContaining({ 'gen_ai.operation.name': 'chat', 'sentry.op': 'gen_ai.chat', - 'sentry.origin': 'auto.function.openai', + 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.temperature': 0.8, @@ -249,7 +249,7 @@ describe('OpenAI integration', () => { }), description: 'chat gpt-4 stream-response', op: 'gen_ai.chat', - origin: 'auto.function.openai', + origin: 'auto.ai.openai', status: 'ok', }), // Fifth span - responses API streaming with PII @@ -257,7 +257,7 @@ describe('OpenAI integration', () => { data: expect.objectContaining({ 'gen_ai.operation.name': 'responses', 'sentry.op': 'gen_ai.responses', - 'sentry.origin': 'auto.function.openai', + 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.stream': true, @@ -278,7 +278,7 @@ describe('OpenAI integration', () => { }), description: 'responses gpt-4 stream-response', op: 'gen_ai.responses', - origin: 'auto.function.openai', + origin: 'auto.ai.openai', status: 'ok', }), // Sixth span - error handling in streaming context with PII @@ -290,11 +290,11 @@ describe('OpenAI integration', () => { 'gen_ai.request.messages': '[{"role":"user","content":"This will fail"}]', 'gen_ai.system': 'openai', 'sentry.op': 'gen_ai.chat', - 'sentry.origin': 'auto.function.openai', + 'sentry.origin': 'auto.ai.openai', }, description: 'chat error-model stream-response', op: 'gen_ai.chat', - origin: 'auto.function.openai', + origin: 'auto.ai.openai', status: 'internal_error', }), ]), @@ -370,7 +370,7 @@ describe('OpenAI integration', () => { data: { 'gen_ai.operation.name': 'chat', 'sentry.op': 'gen_ai.chat', - 'sentry.origin': 'auto.function.openai', + 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-3.5-turbo', 'gen_ai.request.temperature': 0.7, @@ -387,7 +387,7 @@ describe('OpenAI integration', () => { 'openai.usage.prompt_tokens': 10, }, op: 'gen_ai.chat', - origin: 'auto.function.openai', + origin: 'auto.ai.openai', status: 'ok', }, }, diff --git a/packages/core/src/utils/openai/index.ts b/packages/core/src/utils/openai/index.ts index 060117d52964..d296df840112 100644 --- a/packages/core/src/utils/openai/index.ts +++ b/packages/core/src/utils/openai/index.ts @@ -50,7 +50,7 @@ function extractRequestAttributes(args: unknown[], methodPath: string): Record = { [GEN_AI_SYSTEM_ATTRIBUTE]: 'openai', [GEN_AI_OPERATION_NAME_ATTRIBUTE]: getOperationName(methodPath), - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.openai', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.openai', }; // Chat completion API accepts web_search_options and tools as parameters @@ -258,6 +258,10 @@ function instrumentMethod( captureException(error, { mechanism: { handled: false, + type: 'auto.ai.openai.stream', + data: { + function: methodPath, + }, }, }); span.end(); @@ -283,7 +287,15 @@ function instrumentMethod( addResponseAttributes(span, result, finalOptions.recordOutputs); return result; } catch (error) { - captureException(error); + captureException(error, { + mechanism: { + handled: false, + type: 'auto.ai.openai', + data: { + function: methodPath, + }, + }, + }); throw error; } }, diff --git a/packages/core/src/utils/openai/streaming.ts b/packages/core/src/utils/openai/streaming.ts index c79448effb35..0111904ffeae 100644 --- a/packages/core/src/utils/openai/streaming.ts +++ b/packages/core/src/utils/openai/streaming.ts @@ -152,6 +152,7 @@ function processResponsesApiEvent( captureException(streamEvent, { mechanism: { handled: false, + type: 'auto.ai.openai.stream-response', }, }); return; From 68fcc82cda91110aa0f99a4af3fa72f397a2d317 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Fri, 5 Sep 2025 21:27:01 +0200 Subject: [PATCH 08/19] feat(core): Improve error handling for Anthropic AI instrumentation (#17535) This PR enhances Anthropic AI instrumentation to properly handle and record errors that occur as part of response metadata. Core Error Handling Improvements: - Enhanced error detection: Added proper handling for Anthropic API error responses with type: 'error' structure - Improved streaming error handling: Better error type detection and reporting for streaming operations - Error capturing: Manually captured errors for both standard and streaming errors --- .../tracing/anthropic/scenario-errors.mjs | 115 ++++++++++++ .../anthropic/scenario-stream-errors.mjs | 166 ++++++++++++++++++ .../suites/tracing/anthropic/test.ts | 97 ++++++++++ packages/core/src/utils/anthropic-ai/index.ts | 139 +++++++++------ .../core/src/utils/anthropic-ai/streaming.ts | 13 +- packages/core/src/utils/anthropic-ai/types.ts | 15 +- 6 files changed, 484 insertions(+), 61 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-errors.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-stream-errors.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-errors.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-errors.mjs new file mode 100644 index 000000000000..5501ed1a01ff --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-errors.mjs @@ -0,0 +1,115 @@ +import { instrumentAnthropicAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockAnthropic { + constructor(config) { + this.apiKey = config.apiKey; + this.messages = { + create: this._messagesCreate.bind(this), + }; + this.models = { + retrieve: this._modelsRetrieve.bind(this), + }; + } + + async _messagesCreate(params) { + await new Promise(resolve => setTimeout(resolve, 5)); + + // Case 1: Invalid tool format error + if (params.model === 'invalid-format') { + const error = new Error('Invalid format'); + error.status = 400; + error.headers = { 'x-request-id': 'mock-invalid-tool-format-error' }; + throw error; + } + + // Default case (success) - return tool use for successful tool usage test + return { + id: 'msg_ok', + type: 'message', + model: params.model, + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'tool_ok_1', + name: 'calculator', + input: { expression: '2+2' }, + }, + ], + stop_reason: 'tool_use', + usage: { input_tokens: 7, output_tokens: 9 }, + }; + } + + async _modelsRetrieve(modelId) { + await new Promise(resolve => setTimeout(resolve, 5)); + + // Case for model retrieval error + if (modelId === 'nonexistent-model') { + const error = new Error('Model not found'); + error.status = 404; + error.headers = { 'x-request-id': 'mock-model-retrieval-error' }; + throw error; + } + + return { + id: modelId, + name: modelId, + created_at: 1715145600, + model: modelId, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockAnthropic({ apiKey: 'mock-api-key' }); + const client = instrumentAnthropicAiClient(mockClient); + + // 1. Test invalid format error + // https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/implement-tool-use#handling-tool-use-and-tool-result-content-blocks + try { + await client.messages.create({ + model: 'invalid-format', + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'Here are the results:' }, // ❌ Text before tool_result + { type: 'tool_result', tool_use_id: 'toolu_01' }, + ], + }, + ], + }); + } catch { + // Error expected + } + + // 2. Test model retrieval error + try { + await client.models.retrieve('nonexistent-model'); + } catch { + // Error expected + } + + // 3. Test successful tool usage for comparison + await client.messages.create({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'Calculate 2+2' }], + tools: [ + { + name: 'calculator', + description: 'Perform calculations', + input_schema: { + type: 'object', + properties: { expression: { type: 'string' } }, + required: ['expression'], + }, + }, + ], + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-stream-errors.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-stream-errors.mjs new file mode 100644 index 000000000000..9112f96363ce --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-stream-errors.mjs @@ -0,0 +1,166 @@ +import { instrumentAnthropicAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +// Generator for default fallback +function createMockDefaultFallbackStream() { + async function* generator() { + yield { + type: 'content_block_start', + index: 0, + }; + yield { + type: 'content_block_delta', + index: 0, + delta: { text: 'This stream will work fine.' }, + }; + yield { + type: 'content_block_stop', + index: 0, + }; + } + return generator(); +} + +// Generator that errors midway through streaming +function createMockMidwayErrorStream() { + async function* generator() { + // First yield some initial data to start the stream + yield { + type: 'content_block_start', + message: { + id: 'msg_error_stream_1', + type: 'message', + role: 'assistant', + model: 'claude-3-haiku-20240307', + content: [], + usage: { input_tokens: 5 }, + }, + }; + + // Yield one chunk of content + yield { type: 'content_block_delta', delta: { text: 'This stream will ' } }; + + // Then throw an error + await new Promise(resolve => setTimeout(resolve, 5)); + throw new Error('Stream interrupted'); + } + + return generator(); +} + +class MockAnthropic { + constructor(config) { + this.apiKey = config.apiKey; + + this.messages = { + create: this._messagesCreate.bind(this), + stream: this._messagesStream.bind(this), + }; + } + + // client.messages.create with stream: true + async _messagesCreate(params) { + await new Promise(resolve => setTimeout(resolve, 5)); + + // Error on initialization for 'error-stream-init' model + if (params.model === 'error-stream-init') { + if (params?.stream === true) { + throw new Error('Failed to initialize stream'); + } + } + + // Error midway for 'error-stream-midway' model + if (params.model === 'error-stream-midway') { + if (params?.stream === true) { + return createMockMidwayErrorStream(); + } + } + + // Default fallback + return { + id: 'msg_mock123', + type: 'message', + model: params.model, + role: 'assistant', + content: [{ type: 'text', text: 'Non-stream response' }], + usage: { input_tokens: 5, output_tokens: 7 }, + }; + } + + // client.messages.stream + async _messagesStream(params) { + await new Promise(resolve => setTimeout(resolve, 5)); + + // Error on initialization for 'error-stream-init' model + if (params.model === 'error-stream-init') { + throw new Error('Failed to initialize stream'); + } + + // Error midway for 'error-stream-midway' model + if (params.model === 'error-stream-midway') { + return createMockMidwayErrorStream(); + } + + // Default fallback + return createMockDefaultFallbackStream(); + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockAnthropic({ apiKey: 'mock-api-key' }); + const client = instrumentAnthropicAiClient(mockClient); + + // 1) Error on stream initialization with messages.create + try { + await client.messages.create({ + model: 'error-stream-init', + messages: [{ role: 'user', content: 'This will fail immediately' }], + stream: true, + }); + } catch { + // Error expected + } + + // 2) Error on stream initialization with messages.stream + try { + await client.messages.stream({ + model: 'error-stream-init', + messages: [{ role: 'user', content: 'This will also fail immediately' }], + }); + } catch { + // Error expected + } + + // 3) Error midway through streaming with messages.create + try { + const stream = await client.messages.create({ + model: 'error-stream-midway', + messages: [{ role: 'user', content: 'This will fail midway' }], + stream: true, + }); + + for await (const _ of stream) { + void _; + } + } catch { + // Error expected + } + + // 4) Error midway through streaming with messages.stream + try { + const stream = await client.messages.stream({ + model: 'error-stream-midway', + messages: [{ role: 'user', content: 'This will also fail midway' }], + }); + + for await (const _ of stream) { + void _; + } + } catch { + // Error expected + } + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts index 35252f574003..27a0a523b927 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -348,4 +348,101 @@ describe('Anthropic integration', () => { .completed(); }); }); + + // Additional error scenarios - Streaming errors + const EXPECTED_STREAM_ERROR_SPANS = { + transaction: 'main', + spans: expect.arrayContaining([ + // Error with messages.create on stream initialization + expect.objectContaining({ + description: 'messages error-stream-init stream-response', + op: 'gen_ai.messages', + status: 'internal_error', // Actual status coming from the instrumentation + data: expect.objectContaining({ + 'gen_ai.request.model': 'error-stream-init', + 'gen_ai.request.stream': true, + }), + }), + // Error with messages.stream on stream initialization + expect.objectContaining({ + description: 'messages error-stream-init stream-response', + op: 'gen_ai.messages', + status: 'internal_error', // Actual status coming from the instrumentation + data: expect.objectContaining({ + 'gen_ai.request.model': 'error-stream-init', + }), + }), + // Error midway with messages.create on streaming - note: The stream is started successfully + // so we get a successful span with the content that was streamed before the error + expect.objectContaining({ + description: 'messages error-stream-midway stream-response', + op: 'gen_ai.messages', + status: 'ok', + data: expect.objectContaining({ + 'gen_ai.request.model': 'error-stream-midway', + 'gen_ai.request.stream': true, + 'gen_ai.response.streaming': true, + 'gen_ai.response.text': 'This stream will ', // We received some data before error + }), + }), + // Error midway with messages.stream - same behavior, we get a span with the streamed data + expect.objectContaining({ + description: 'messages error-stream-midway stream-response', + op: 'gen_ai.messages', + status: 'ok', + data: expect.objectContaining({ + 'gen_ai.request.model': 'error-stream-midway', + 'gen_ai.response.streaming': true, + 'gen_ai.response.text': 'This stream will ', // We received some data before error + }), + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario-stream-errors.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('handles streaming errors correctly', async () => { + await createRunner().ignore('event').expect({ transaction: EXPECTED_STREAM_ERROR_SPANS }).start().completed(); + }); + }); + + // Additional error scenarios - Tool errors and model retrieval errors + const EXPECTED_ERROR_SPANS = { + transaction: 'main', + spans: expect.arrayContaining([ + // Invalid tool format error + expect.objectContaining({ + description: 'messages invalid-format', + op: 'gen_ai.messages', + status: 'unknown_error', + data: expect.objectContaining({ + 'gen_ai.request.model': 'invalid-format', + }), + }), + // Model retrieval error + expect.objectContaining({ + description: 'models nonexistent-model', + op: 'gen_ai.models', + status: 'unknown_error', + data: expect.objectContaining({ + 'gen_ai.request.model': 'nonexistent-model', + }), + }), + // Successful tool usage (for comparison) + expect.objectContaining({ + description: 'messages claude-3-haiku-20240307', + op: 'gen_ai.messages', + status: 'ok', + data: expect.objectContaining({ + 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.response.tool_calls': expect.stringContaining('tool_ok_1'), + }), + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario-errors.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('handles tool errors and model retrieval errors correctly', async () => { + await createRunner().ignore('event').expect({ transaction: EXPECTED_ERROR_SPANS }).start().completed(); + }); + }); }); diff --git a/packages/core/src/utils/anthropic-ai/index.ts b/packages/core/src/utils/anthropic-ai/index.ts index c54fdc2a8a9c..563724d98c5c 100644 --- a/packages/core/src/utils/anthropic-ai/index.ts +++ b/packages/core/src/utils/anthropic-ai/index.ts @@ -90,71 +90,110 @@ function addPrivateRequestAttributes(span: Span, params: Record } /** - * Add response attributes to spans + * Capture error information from the response + * @see https://docs.anthropic.com/en/api/errors#error-shapes */ -function addResponseAttributes(span: Span, response: AnthropicAiResponse, recordOutputs?: boolean): void { - if (!response || typeof response !== 'object') return; +function handleResponseError(span: Span, response: AnthropicAiResponse): void { + if (response.error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: response.error.type || 'unknown_error' }); - // Private response attributes that are only recorded if recordOutputs is true. - if (recordOutputs) { - // Messages.create - if ('content' in response) { - if (Array.isArray(response.content)) { - span.setAttributes({ - [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: response.content - .map((item: ContentBlock) => item.text) - .filter(text => !!text) - .join(''), - }); + captureException(response.error, { + mechanism: { + handled: false, + type: 'auto.ai.anthropic.anthropic_error', + }, + }); + } +} - const toolCalls: Array = []; +/** + * Add content attributes when recordOutputs is enabled + */ +function addContentAttributes(span: Span, response: AnthropicAiResponse): void { + // Messages.create + if ('content' in response) { + if (Array.isArray(response.content)) { + span.setAttributes({ + [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: response.content + .map((item: ContentBlock) => item.text) + .filter(text => !!text) + .join(''), + }); - for (const item of response.content) { - if (item.type === 'tool_use' || item.type === 'server_tool_use') { - toolCalls.push(item); - } - } - if (toolCalls.length > 0) { - span.setAttributes({ [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(toolCalls) }); + const toolCalls: Array = []; + + for (const item of response.content) { + if (item.type === 'tool_use' || item.type === 'server_tool_use') { + toolCalls.push(item); } } + if (toolCalls.length > 0) { + span.setAttributes({ [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(toolCalls) }); + } } - // Completions.create - if ('completion' in response) { - span.setAttributes({ [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: response.completion }); - } - // Models.countTokens - if ('input_tokens' in response) { - span.setAttributes({ [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: JSON.stringify(response.input_tokens) }); - } } + // Completions.create + if ('completion' in response) { + span.setAttributes({ [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: response.completion }); + } + // Models.countTokens + if ('input_tokens' in response) { + span.setAttributes({ [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: JSON.stringify(response.input_tokens) }); + } +} - span.setAttributes({ - [GEN_AI_RESPONSE_ID_ATTRIBUTE]: response.id, - }); - span.setAttributes({ - [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: response.model, - }); - if ('created' in response && typeof response.created === 'number') { +/** + * Add basic metadata attributes from the response + */ +function addMetadataAttributes(span: Span, response: AnthropicAiResponse): void { + if ('id' in response && 'model' in response) { span.setAttributes({ - [ANTHROPIC_AI_RESPONSE_TIMESTAMP_ATTRIBUTE]: new Date(response.created * 1000).toISOString(), + [GEN_AI_RESPONSE_ID_ATTRIBUTE]: response.id, + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: response.model, }); + + if ('created' in response && typeof response.created === 'number') { + span.setAttributes({ + [ANTHROPIC_AI_RESPONSE_TIMESTAMP_ATTRIBUTE]: new Date(response.created * 1000).toISOString(), + }); + } + if ('created_at' in response && typeof response.created_at === 'number') { + span.setAttributes({ + [ANTHROPIC_AI_RESPONSE_TIMESTAMP_ATTRIBUTE]: new Date(response.created_at * 1000).toISOString(), + }); + } + + if ('usage' in response && response.usage) { + setTokenUsageAttributes( + span, + response.usage.input_tokens, + response.usage.output_tokens, + response.usage.cache_creation_input_tokens, + response.usage.cache_read_input_tokens, + ); + } } - if ('created_at' in response && typeof response.created_at === 'number') { - span.setAttributes({ - [ANTHROPIC_AI_RESPONSE_TIMESTAMP_ATTRIBUTE]: new Date(response.created_at * 1000).toISOString(), - }); +} + +/** + * Add response attributes to spans + */ +function addResponseAttributes(span: Span, response: AnthropicAiResponse, recordOutputs?: boolean): void { + if (!response || typeof response !== 'object') return; + + // capture error, do not add attributes if error (they shouldn't exist) + if ('type' in response && response.type === 'error') { + handleResponseError(span, response); + return; } - if (response.usage) { - setTokenUsageAttributes( - span, - response.usage.input_tokens, - response.usage.output_tokens, - response.usage.cache_creation_input_tokens, - response.usage.cache_read_input_tokens, - ); + // Private response attributes that are only recorded if recordOutputs is true. + if (recordOutputs) { + addContentAttributes(span, response); } + + // Add basic metadata attributes + addMetadataAttributes(span, response); } /** diff --git a/packages/core/src/utils/anthropic-ai/streaming.ts b/packages/core/src/utils/anthropic-ai/streaming.ts index c48dc8a6def7..cd30d99ad09e 100644 --- a/packages/core/src/utils/anthropic-ai/streaming.ts +++ b/packages/core/src/utils/anthropic-ai/streaming.ts @@ -60,18 +60,11 @@ function isErrorEvent(event: AnthropicAiStreamingEvent, span: Span): boolean { // If the event is an error, set the span status and capture the error // These error events are not rejected by the API by default, but are sent as metadata of the response if (event.type === 'error') { - const message = event.error?.message ?? 'internal_error'; - span.setStatus({ code: SPAN_STATUS_ERROR, message }); - captureException(new Error(`anthropic_stream_error: ${message}`), { + span.setStatus({ code: SPAN_STATUS_ERROR, message: event.error?.type ?? 'unknown_error' }); + captureException(event.error, { mechanism: { handled: false, - type: 'auto.ai.anthropic', - data: { - function: 'anthropic_stream_error', - }, - }, - data: { - function: 'anthropic_stream_error', + type: 'auto.ai.anthropic.anthropic_error', }, }); return true; diff --git a/packages/core/src/utils/anthropic-ai/types.ts b/packages/core/src/utils/anthropic-ai/types.ts index 6ab2e790e651..124b7c7f73be 100644 --- a/packages/core/src/utils/anthropic-ai/types.ts +++ b/packages/core/src/utils/anthropic-ai/types.ts @@ -27,7 +27,17 @@ export type ContentBlock = { tool_use_id?: string; }; -export type AnthropicAiResponse = { +// @see https://docs.anthropic.com/en/api/errors#error-shapes +export type MessageError = { + type: 'error'; + error: { + type: string; + message: string; + }; + request_id: string; +}; + +type SuccessfulResponse = { [key: string]: unknown; // Allow for additional unknown properties id: string; model: string; @@ -43,8 +53,11 @@ export type AnthropicAiResponse = { cache_creation_input_tokens: number; cache_read_input_tokens: number; }; + error?: never; // This should help TypeScript infer the type correctly }; +export type AnthropicAiResponse = SuccessfulResponse | MessageError; + /** * Basic interface for Anthropic AI client with only the instrumented methods * This provides type safety while being generic enough to work with different client implementations From ef651c51176e00722a319c6bd8ccf05a9de7168e Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 8 Sep 2025 09:16:22 +0200 Subject: [PATCH 09/19] chore: Use proper `test-utils` dependency in workspace (#17538) We only need to use the `link:...` syntax in E2E tests which are not in the workspace. Also cleans up some other deps that somehow are not in sync in yarn.lock...? --- .../cloudflare-integration-tests/package.json | 2 +- .../node-integration-tests/package.json | 2 +- yarn.lock | 38 +++++++------------ 3 files changed, 16 insertions(+), 26 deletions(-) diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index 914bcbfb2765..c4ff35a17aeb 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -17,7 +17,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4.20250708.0", - "@sentry-internal/test-utils": "link:../test-utils", + "@sentry-internal/test-utils": "10.10.0", "vitest": "^3.2.4", "wrangler": "4.22.0" }, diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index fc6f1c8119ac..1acaa0a3e690 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -70,7 +70,7 @@ "yargs": "^16.2.0" }, "devDependencies": { - "@sentry-internal/test-utils": "link:../test-utils", + "@sentry-internal/test-utils": "10.10.0", "@types/amqplib": "^0.10.5", "@types/node-cron": "^3.0.11", "@types/node-schedule": "^2.1.7", diff --git a/yarn.lock b/yarn.lock index c3a81b3cc097..d22046031f12 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6943,11 +6943,6 @@ fflate "^0.4.4" mitt "^3.0.0" -"@sentry-internal/test-utils@link:dev-packages/test-utils": - version "10.8.0" - dependencies: - express "^4.21.1" - "@sentry/babel-plugin-component-annotate@4.1.0": version "4.1.0" resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.1.0.tgz#6e7168f5fa59f53ac4b68e3f79c5fd54adc13f2e" @@ -6958,11 +6953,6 @@ resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.1.1.tgz#371415afc602f6b2ba0987b51123bd34d1603193" integrity sha512-HUpqrCK7zDVojTV6KL6BO9ZZiYrEYQqvYQrscyMsq04z+WCupXaH6YEliiNRvreR8DBJgdsG3lBRpebhUGmvfA== -"@sentry/babel-plugin-component-annotate@4.2.0": - version "4.2.0" - resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.2.0.tgz#6c616e6d645f49f15f83b891ef42a795ba4dbb3f" - integrity sha512-GFpS3REqaHuyX4LCNqlneAQZIKyHb5ePiI1802n0fhtYjk68I1DTQ3PnbzYi50od/vAsTQVCknaS5F6tidNqTQ== - "@sentry/babel-plugin-component-annotate@4.3.0": version "4.3.0" resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.3.0.tgz#c5b6cbb986952596d3ad233540a90a1fd18bad80" @@ -6996,20 +6986,6 @@ magic-string "0.30.8" unplugin "1.0.1" -"@sentry/bundler-plugin-core@4.2.0": - version "4.2.0" - resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.2.0.tgz#b607937f7cd0a769aa26974c4af3fca94abad63f" - integrity sha512-EDG6ELSEN/Dzm4KUQOynoI2suEAdPdgwaBXVN4Ww705zdrYT79OGh51rkz74KGhovt7GukaPf0Z9LJwORXUbhg== - dependencies: - "@babel/core" "^7.18.5" - "@sentry/babel-plugin-component-annotate" "4.2.0" - "@sentry/cli" "^2.51.0" - dotenv "^16.3.1" - find-up "^5.0.0" - glob "^9.3.2" - magic-string "0.30.8" - unplugin "1.0.1" - "@sentry/bundler-plugin-core@4.3.0", "@sentry/bundler-plugin-core@^4.3.0": version "4.3.0" resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.3.0.tgz#cf302522a3e5b8a3bf727635d0c6a7bece981460" @@ -14255,6 +14231,9 @@ detective-scss@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/detective-scss/-/detective-scss-5.0.1.tgz#6a7f792dc9c0e8cfc0d252a50ba26a6df12596a7" integrity sha512-MAyPYRgS6DCiS6n6AoSBJXLGVOydsr9huwXORUlJ37K3YLyiN0vYHpzs3AdJOgHobBfispokoqrEon9rbmKacg== + dependencies: + gonzales-pe "^4.3.0" + node-source-walk "^7.0.1" detective-stylus@^4.0.0: version "4.0.0" @@ -14289,6 +14268,14 @@ detective-vue2@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/detective-vue2/-/detective-vue2-2.2.0.tgz#35fd1d39e261b064aca9fcaf20e136c76877482a" integrity sha512-sVg/t6O2z1zna8a/UIV6xL5KUa2cMTQbdTIIvqNM0NIPswp52fe43Nwmbahzj3ww4D844u/vC2PYfiGLvD3zFA== + dependencies: + "@dependents/detective-less" "^5.0.1" + "@vue/compiler-sfc" "^3.5.13" + detective-es6 "^5.0.1" + detective-sass "^6.0.1" + detective-scss "^5.0.1" + detective-stylus "^5.0.1" + detective-typescript "^14.0.0" deterministic-object-hash@^1.3.1: version "1.3.1" @@ -16820,6 +16807,9 @@ fetch-blob@^3.1.2, fetch-blob@^3.1.4: version "3.2.0" resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9" integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ== + dependencies: + node-domexception "^1.0.0" + web-streams-polyfill "^3.0.3" fflate@0.8.2, fflate@^0.8.2: version "0.8.2" From e7beae66473c2972438c999c8f1f77aad1d100e3 Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Mon, 8 Sep 2025 09:48:23 +0200 Subject: [PATCH 10/19] feat(aws): Add experimental AWS Lambda extension for tunnelling events (#17525) This introduces a new experimental Sentry Lambda extension within the existing Sentry Lambda layer. Sentry events are being tunnelled through the extension, where they are then forwarded to Sentry. Initial benchmarks using ApacheBench show a reduction in request processing time of around 26% (from 243ms to 180ms on average over 100 requests; function pre-warmed). To enable it, set `_experiments.enableLambdaExtension` in your Sentry config like this: ```js Sentry.init({ // ...other config dsn: "", _experiments: { enableLambdaExtension: true } }) ``` closes #12856 relates to #3051 --- .../ExperimentalExtension/index.mjs | 16 ++ .../aws-serverless/tests/layer.test.ts | 48 ++++++ dev-packages/rollup-utils/bundleHelpers.mjs | 18 ++- packages/aws-serverless/package.json | 2 +- .../rollup.lambda-extension.config.mjs | 15 ++ .../scripts/buildLambdaLayer.ts | 5 + packages/aws-serverless/src/init.ts | 35 ++++- .../lambda-extension/aws-lambda-extension.ts | 145 ++++++++++++++++++ .../src/lambda-extension/debug-build.ts | 8 + .../src/lambda-extension/index.ts | 21 +++ .../src/lambda-extension/sentry-extension | 8 + packages/aws-serverless/test/init.test.ts | 104 +++++++++++++ 12 files changed, 416 insertions(+), 9 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-layer/ExperimentalExtension/index.mjs create mode 100644 packages/aws-serverless/rollup.lambda-extension.config.mjs create mode 100644 packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts create mode 100644 packages/aws-serverless/src/lambda-extension/debug-build.ts create mode 100644 packages/aws-serverless/src/lambda-extension/index.ts create mode 100644 packages/aws-serverless/src/lambda-extension/sentry-extension create mode 100644 packages/aws-serverless/test/init.test.ts diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-layer/ExperimentalExtension/index.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-layer/ExperimentalExtension/index.mjs new file mode 100644 index 000000000000..d4cd56b78c90 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-layer/ExperimentalExtension/index.mjs @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/aws-serverless'; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + tracesSampleRate: 1, + debug: true, + _experiments: { + enableLambdaExtension: true, + }, +}); + +export const handler = async (event, context) => { + Sentry.startSpan({ name: 'manual-span', op: 'test' }, async () => { + return 'Hello, world!'; + }); +}; diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts index 4d68efb66b08..0439aba5f53c 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts @@ -242,4 +242,52 @@ test.describe('Lambda layer', () => { }), ); }); + + test('experimental extension works', async ({ lambdaClient }) => { + const transactionEventPromise = waitForTransaction('aws-serverless-lambda-sam', transactionEvent => { + return transactionEvent?.transaction === 'LayerExperimentalExtension'; + }); + + await lambdaClient.send( + new InvokeCommand({ + FunctionName: 'LayerExperimentalExtension', + Payload: JSON.stringify({}), + }), + ); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.transaction).toEqual('LayerExperimentalExtension'); + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'sentry.sample_rate': 1, + 'sentry.source': 'custom', + 'sentry.origin': 'auto.otel.aws-lambda', + 'sentry.op': 'function.aws.lambda', + 'cloud.account.id': '012345678912', + 'faas.execution': expect.any(String), + 'faas.id': 'arn:aws:lambda:us-east-1:012345678912:function:LayerExperimentalExtension', + 'faas.coldstart': true, + 'otel.kind': 'SERVER', + }, + op: 'function.aws.lambda', + origin: 'auto.otel.aws-lambda', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(transactionEvent.spans).toHaveLength(1); + + expect(transactionEvent.spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.op': 'test', + 'sentry.origin': 'manual', + }), + description: 'manual-span', + op: 'test', + }), + ); + }); }); diff --git a/dev-packages/rollup-utils/bundleHelpers.mjs b/dev-packages/rollup-utils/bundleHelpers.mjs index 1099cb6b6549..b353eebaa214 100644 --- a/dev-packages/rollup-utils/bundleHelpers.mjs +++ b/dev-packages/rollup-utils/bundleHelpers.mjs @@ -53,7 +53,7 @@ export function makeBaseBundleConfig(options) { }, }, context: 'window', - plugins: [rrwebBuildPlugin, markAsBrowserBuildPlugin], + plugins: [rrwebBuildPlugin, markAsBrowserBuildPlugin, licensePlugin], }; // used by `@sentry/wasm` & pluggable integrations from core/browser (bundles which need to be combined with a stand-alone SDK bundle) @@ -87,14 +87,23 @@ export function makeBaseBundleConfig(options) { // code to add after the CJS wrapper footer: '}(window));', }, - plugins: [rrwebBuildPlugin, markAsBrowserBuildPlugin], + plugins: [rrwebBuildPlugin, markAsBrowserBuildPlugin, licensePlugin], }; const workerBundleConfig = { output: { format: 'esm', }, - plugins: [commonJSPlugin, makeTerserPlugin()], + plugins: [commonJSPlugin, makeTerserPlugin(), licensePlugin], + // Don't bundle any of Node's core modules + external: builtinModules, + }; + + const awsLambdaExtensionBundleConfig = { + output: { + format: 'esm', + }, + plugins: [commonJSPlugin, makeIsDebugBuildPlugin(true), makeTerserPlugin()], // Don't bundle any of Node's core modules external: builtinModules, }; @@ -110,7 +119,7 @@ export function makeBaseBundleConfig(options) { strict: false, esModule: false, }, - plugins: [sucrasePlugin, nodeResolvePlugin, cleanupPlugin, licensePlugin], + plugins: [sucrasePlugin, nodeResolvePlugin, cleanupPlugin], treeshake: 'smallest', }; @@ -118,6 +127,7 @@ export function makeBaseBundleConfig(options) { standalone: standAloneBundleConfig, addon: addOnBundleConfig, 'node-worker': workerBundleConfig, + 'lambda-extension': awsLambdaExtensionBundleConfig, }; return deepMerge.all([sharedBundleConfig, bundleTypeConfigMap[bundleType], packageSpecificConfig || {}], { diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 62dad1c3a5a9..c1e6937c021b 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -79,7 +79,7 @@ }, "scripts": { "build": "run-p build:transpile build:types", - "build:layer": "yarn ts-node scripts/buildLambdaLayer.ts", + "build:layer": "rollup -c rollup.lambda-extension.config.mjs && yarn ts-node scripts/buildLambdaLayer.ts", "build:dev": "run-p build:transpile build:types", "build:transpile": "rollup -c rollup.npm.config.mjs && yarn build:layer", "build:types": "run-s build:types:core build:types:downlevel", diff --git a/packages/aws-serverless/rollup.lambda-extension.config.mjs b/packages/aws-serverless/rollup.lambda-extension.config.mjs new file mode 100644 index 000000000000..cf7f369d9175 --- /dev/null +++ b/packages/aws-serverless/rollup.lambda-extension.config.mjs @@ -0,0 +1,15 @@ +import { makeBaseBundleConfig } from '@sentry-internal/rollup-utils'; + +export default [ + makeBaseBundleConfig({ + bundleType: 'lambda-extension', + entrypoints: ['src/lambda-extension/index.ts'], + outputFileBase: 'index.mjs', + packageSpecificConfig: { + output: { + dir: 'build/aws/dist-serverless/sentry-extension', + sourcemap: false, + }, + }, + }), +]; diff --git a/packages/aws-serverless/scripts/buildLambdaLayer.ts b/packages/aws-serverless/scripts/buildLambdaLayer.ts index a918e6bbae18..c12d8bd70d77 100644 --- a/packages/aws-serverless/scripts/buildLambdaLayer.ts +++ b/packages/aws-serverless/scripts/buildLambdaLayer.ts @@ -45,6 +45,11 @@ async function buildLambdaLayer(): Promise { replaceSDKSource(); + fsForceMkdirSync('./build/aws/dist-serverless/extensions'); + fs.copyFileSync('./src/lambda-extension/sentry-extension', './build/aws/dist-serverless/extensions/sentry-extension'); + fs.chmodSync('./build/aws/dist-serverless/extensions/sentry-extension', 0o755); + fs.chmodSync('./build/aws/dist-serverless/sentry-extension/index.mjs', 0o755); + const zipFilename = `sentry-node-serverless-${version}.zip`; console.log(`Creating final layer zip file ${zipFilename}.`); // need to preserve the symlink above with -y diff --git a/packages/aws-serverless/src/init.ts b/packages/aws-serverless/src/init.ts index 269cc3fe27fb..9de744bedf34 100644 --- a/packages/aws-serverless/src/init.ts +++ b/packages/aws-serverless/src/init.ts @@ -1,10 +1,10 @@ import type { Integration, Options } from '@sentry/core'; -import { applySdkMetadata, getSDKSource } from '@sentry/core'; +import { applySdkMetadata, debug, getSDKSource } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegrations } from '@sentry/node'; +import { DEBUG_BUILD } from './debug-build'; import { awsIntegration } from './integration/aws'; import { awsLambdaIntegration } from './integration/awslambda'; - /** * Get the default integrations for the AWSLambda SDK. */ @@ -14,18 +14,45 @@ export function getDefaultIntegrations(_options: Options): Integration[] { return [...getDefaultIntegrationsWithoutPerformance(), awsIntegration(), awsLambdaIntegration()]; } +export interface AwsServerlessOptions extends NodeOptions { + _experiments?: NodeOptions['_experiments'] & { + /** + * If proxying Sentry events through the Sentry Lambda extension should be enabled. + */ + enableLambdaExtension?: boolean; + }; +} + /** * Initializes the Sentry AWS Lambda SDK. * * @param options Configuration options for the SDK, @see {@link AWSLambdaOptions}. */ -export function init(options: NodeOptions = {}): NodeClient | undefined { +export function init(options: AwsServerlessOptions = {}): NodeClient | undefined { const opts = { defaultIntegrations: getDefaultIntegrations(options), ...options, }; - applySdkMetadata(opts, 'aws-serverless', ['aws-serverless'], getSDKSource()); + const sdkSource = getSDKSource(); + + if (opts._experiments?.enableLambdaExtension) { + if (sdkSource === 'aws-lambda-layer') { + if (!opts.tunnel) { + DEBUG_BUILD && debug.log('Proxying Sentry events through the Sentry Lambda extension'); + opts.tunnel = 'http://localhost:9000/envelope'; + } else { + DEBUG_BUILD && + debug.warn( + `Using a custom tunnel with the Sentry Lambda extension is not supported. Events will be tunnelled to ${opts.tunnel} and not through the extension.`, + ); + } + } else { + DEBUG_BUILD && debug.warn('The Sentry Lambda extension is only supported when using the AWS Lambda layer.'); + } + } + + applySdkMetadata(opts, 'aws-serverless', ['aws-serverless'], sdkSource); return initWithoutDefaultIntegrations(opts); } diff --git a/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts b/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts new file mode 100644 index 000000000000..ff2228fffabe --- /dev/null +++ b/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts @@ -0,0 +1,145 @@ +import * as http from 'node:http'; +import { buffer } from 'node:stream/consumers'; +import { debug, dsnFromString, getEnvelopeEndpointWithUrlEncodedAuth } from '@sentry/core'; +import { DEBUG_BUILD } from './debug-build'; + +/** + * The Extension API Client. + */ +export class AwsLambdaExtension { + private readonly _baseUrl: string; + private _extensionId: string | null; + + public constructor() { + this._baseUrl = `http://${process.env.AWS_LAMBDA_RUNTIME_API}/2020-01-01/extension`; + this._extensionId = null; + } + + /** + * Register this extension as an external extension with AWS. + */ + public async register(): Promise { + const res = await fetch(`${this._baseUrl}/register`, { + method: 'POST', + body: JSON.stringify({ + events: ['INVOKE', 'SHUTDOWN'], + }), + headers: { + 'Content-Type': 'application/json', + 'Lambda-Extension-Name': 'sentry-extension', + }, + }); + + if (!res.ok) { + throw new Error(`Failed to register with the extension API: ${await res.text()}`); + } + + this._extensionId = res.headers.get('lambda-extension-identifier'); + } + + /** + * Advances the extension to the next event. + */ + public async next(): Promise { + if (!this._extensionId) { + throw new Error('Extension ID is not set'); + } + + const res = await fetch(`${this._baseUrl}/event/next`, { + headers: { + 'Lambda-Extension-Identifier': this._extensionId, + 'Content-Type': 'application/json', + }, + }); + + if (!res.ok) { + throw new Error(`Failed to advance to next event: ${await res.text()}`); + } + } + + /** + * Reports an error to the extension API. + * @param phase The phase of the extension. + * @param err The error to report. + */ + public async error(phase: 'init' | 'exit', err: Error): Promise { + if (!this._extensionId) { + throw new Error('Extension ID is not set'); + } + + const errorType = `Extension.${err.name || 'UnknownError'}`; + + const res = await fetch(`${this._baseUrl}/${phase}/error`, { + method: 'POST', + body: JSON.stringify({ + errorMessage: err.message || err.toString(), + errorType, + stackTrace: [err.stack], + }), + headers: { + 'Content-Type': 'application/json', + 'Lambda-Extension-Identifier': this._extensionId, + 'Lambda-Extension-Function-Error': errorType, + }, + }); + + if (!res.ok) { + DEBUG_BUILD && debug.error(`Failed to report error: ${await res.text()}`); + } + + throw err; + } + + /** + * Starts the Sentry tunnel. + */ + public startSentryTunnel(): void { + const server = http.createServer(async (req, res) => { + if (req.method === 'POST' && req.url?.startsWith('/envelope')) { + try { + const buf = await buffer(req); + // Extract the actual bytes from the Buffer by slicing its underlying ArrayBuffer + // This ensures we get only the data portion without any padding or offset + const envelopeBytes = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); + const envelope = new TextDecoder().decode(envelopeBytes); + const piece = envelope.split('\n')[0]; + const header = JSON.parse(piece || '{}') as { dsn?: string }; + if (!header.dsn) { + throw new Error('DSN is not set'); + } + const dsn = dsnFromString(header.dsn); + if (!dsn) { + throw new Error('Invalid DSN'); + } + const upstreamSentryUrl = getEnvelopeEndpointWithUrlEncodedAuth(dsn); + + fetch(upstreamSentryUrl, { + method: 'POST', + body: envelopeBytes, + }).catch(err => { + DEBUG_BUILD && debug.error('Error sending envelope to Sentry', err); + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({})); + } catch (e) { + DEBUG_BUILD && debug.error('Error tunneling to Sentry', e); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Error tunneling to Sentry' })); + } + } else { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found' })); + } + }); + + server.listen(9000, () => { + DEBUG_BUILD && debug.log('Sentry proxy listening on port 9000'); + }); + + server.on('error', err => { + DEBUG_BUILD && debug.error('Error starting Sentry proxy', err); + process.exit(1); + }); + } +} diff --git a/packages/aws-serverless/src/lambda-extension/debug-build.ts b/packages/aws-serverless/src/lambda-extension/debug-build.ts new file mode 100644 index 000000000000..60aa50940582 --- /dev/null +++ b/packages/aws-serverless/src/lambda-extension/debug-build.ts @@ -0,0 +1,8 @@ +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/aws-serverless/src/lambda-extension/index.ts b/packages/aws-serverless/src/lambda-extension/index.ts new file mode 100644 index 000000000000..f465dae9741d --- /dev/null +++ b/packages/aws-serverless/src/lambda-extension/index.ts @@ -0,0 +1,21 @@ +#!/usr/bin/env node +import { debug } from '@sentry/core'; +import { AwsLambdaExtension } from './aws-lambda-extension'; +import { DEBUG_BUILD } from './debug-build'; + +async function main(): Promise { + const extension = new AwsLambdaExtension(); + + await extension.register(); + + extension.startSentryTunnel(); + + // eslint-disable-next-line no-constant-condition + while (true) { + await extension.next(); + } +} + +main().catch(err => { + DEBUG_BUILD && debug.error('Error in Lambda Extension', err); +}); diff --git a/packages/aws-serverless/src/lambda-extension/sentry-extension b/packages/aws-serverless/src/lambda-extension/sentry-extension new file mode 100644 index 000000000000..a6c355b4a615 --- /dev/null +++ b/packages/aws-serverless/src/lambda-extension/sentry-extension @@ -0,0 +1,8 @@ +#!/bin/bash +set -euo pipefail + +OWN_FILENAME="$(basename $0)" +LAMBDA_EXTENSION_NAME="$OWN_FILENAME" # (external) extension name has to match the filename + +unset NODE_OPTIONS +exec "/opt/${LAMBDA_EXTENSION_NAME}/index.mjs" diff --git a/packages/aws-serverless/test/init.test.ts b/packages/aws-serverless/test/init.test.ts new file mode 100644 index 000000000000..b4aa7ddc0d2b --- /dev/null +++ b/packages/aws-serverless/test/init.test.ts @@ -0,0 +1,104 @@ +import { getSDKSource } from '@sentry/core'; +import { initWithoutDefaultIntegrations } from '@sentry/node'; +import { describe, expect, test, vi } from 'vitest'; +import type { AwsServerlessOptions } from '../src/init'; +import { init } from '../src/init'; + +vi.mock('@sentry/core', async importOriginal => ({ + ...(await importOriginal()), + getSDKSource: vi.fn(), +})); + +vi.mock('@sentry/node', async importOriginal => ({ + ...(await importOriginal()), + initWithoutDefaultIntegrations: vi.fn(), +})); + +const mockGetSDKSource = vi.mocked(getSDKSource); +const mockInitWithoutDefaultIntegrations = vi.mocked(initWithoutDefaultIntegrations); + +describe('init', () => { + describe('experimental Lambda extension support', () => { + test('should preserve user-provided tunnel option when Lambda extension is enabled', () => { + mockGetSDKSource.mockReturnValue('aws-lambda-layer'); + const options: AwsServerlessOptions = { + tunnel: 'https://custom-tunnel.example.com', + _experiments: { + enableLambdaExtension: true, + }, + }; + + init(options); + + expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( + expect.objectContaining({ + tunnel: 'https://custom-tunnel.example.com', + }), + ); + }); + + test('should set default tunnel when Lambda extension is enabled and SDK source is aws-lambda-layer', () => { + mockGetSDKSource.mockReturnValue('aws-lambda-layer'); + const options: AwsServerlessOptions = { + _experiments: { + enableLambdaExtension: true, + }, + }; + + init(options); + + expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( + expect.objectContaining({ + tunnel: 'http://localhost:9000/envelope', + }), + ); + }); + + test('should not set tunnel when Lambda extension is disabled', () => { + mockGetSDKSource.mockReturnValue('aws-lambda-layer'); + const options: AwsServerlessOptions = { + _experiments: { + enableLambdaExtension: false, + }, + }; + + init(options); + + expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( + expect.not.objectContaining({ + tunnel: expect.any(String), + }), + ); + }); + + test('should not set tunnel when SDK source is not aws-lambda-layer even with Lambda extension enabled', () => { + mockGetSDKSource.mockReturnValue('npm'); + const options: AwsServerlessOptions = { + _experiments: { + enableLambdaExtension: true, + }, + }; + + init(options); + + expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( + expect.not.objectContaining({ + tunnel: expect.any(String), + }), + ); + }); + + test('should not set tunnel when no experiments are provided', () => { + mockGetSDKSource.mockReturnValue('aws-lambda-layer'); + const options: AwsServerlessOptions = {}; + + init(options); + + expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( + expect.not.objectContaining({ + tunnel: expect.any(String), + }), + ); + }); + }); +}); From 5441df9fd8cdc713b4099f373f82adee4ddc236d Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 8 Sep 2025 09:52:49 +0200 Subject: [PATCH 11/19] fix(astro): Ensure traces are correctly propagated for static routes (#17536) This refactors the astro middleware a bit to be more correct & future proof. Right now, http.server spans are not emitted by the node http integration because import-in-the-middle does not work with Astro. So we emit our own. This PR makes astro ready to also handle the base http.server spans and enhance them with routing information. It also handles static routes better: these do not emit spans anymore now, and do not continue traces, but instead only propagate the parametrized route to the client, no trace data. One fundamental problem remains that is not fixed in this PR: `Sentry.init()` is only injected via `page-ssr` into SSR pages. This means that only once any SSR page is hit, the server-side part of Sentry is initialized. If you hit a prerendered (static) page first, sentry will not be initialized and thus neither errors are captured nor spans created. For regular prerendered pages this should be fine because we do not need to run anything at runtime there. However, when you hit a prerendered page that has a server-island, the server island (which is dynamic) will not be instrumented either because Sentry is not initialized yet. I don't think we can fix this with the current set of primitives we have, so this is a future problem to look into... --------- Co-authored-by: Lukas Stracke --- .../test-applications/astro-4/package.json | 2 +- .../astro-4/playwright.config.mjs | 2 +- .../astro-4/tests/errors.client.test.ts | 1 - .../astro-4/tests/tracing.static.test.ts | 16 +- .../test-applications/astro-5/package.json | 1 + .../astro-5/playwright.config.mjs | 2 +- .../astro-5/tests/errors.client.test.ts | 1 - .../tests/tracing.serverIslands.test.ts | 21 +- .../astro-5/tests/tracing.static.test.ts | 16 +- packages/astro/src/server/middleware.ts | 443 +++++++++++------- packages/astro/test/server/middleware.test.ts | 249 +++++----- 11 files changed, 447 insertions(+), 307 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/astro-4/package.json b/dev-packages/e2e-tests/test-applications/astro-4/package.json index d355f35e6315..df0750ee226c 100644 --- a/dev-packages/e2e-tests/test-applications/astro-4/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-4/package.json @@ -4,9 +4,9 @@ "version": "0.0.1", "scripts": { "dev": "astro dev --force", - "start": "astro dev", "build": "astro check && astro build", "preview": "astro preview", + "start": "node ./dist/server/entry.mjs", "astro": "astro", "test:build": "pnpm install && pnpm build", "test:assert": "TEST_ENV=production playwright test" diff --git a/dev-packages/e2e-tests/test-applications/astro-4/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/astro-4/playwright.config.mjs index cd6ed611fb4a..ae58e4ff3ddc 100644 --- a/dev-packages/e2e-tests/test-applications/astro-4/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/astro-4/playwright.config.mjs @@ -7,7 +7,7 @@ if (!testEnv) { } const config = getPlaywrightConfig({ - startCommand: 'node ./dist/server/entry.mjs', + startCommand: 'pnpm start', }); export default config; diff --git a/dev-packages/e2e-tests/test-applications/astro-4/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/astro-4/tests/errors.client.test.ts index 730122f5c208..afa786c9fcd1 100644 --- a/dev-packages/e2e-tests/test-applications/astro-4/tests/errors.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-4/tests/errors.client.test.ts @@ -70,7 +70,6 @@ test.describe('client-side errors', () => { contexts: { trace: { trace_id: expect.stringMatching(/[a-f0-9]{32}/), - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), }, }, diff --git a/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.static.test.ts b/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.static.test.ts index 30bcbee1a026..7af2115dc29e 100644 --- a/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.static.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.static.test.ts @@ -21,14 +21,14 @@ test.describe('tracing in static/pre-rendered routes', () => { const clientPageloadTraceId = clientPageloadTxn.contexts?.trace?.trace_id; const clientPageloadParentSpanId = clientPageloadTxn.contexts?.trace?.parent_span_id; - const sentryTraceMetaTagContent = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); - const baggageMetaTagContent = await page.locator('meta[name="baggage"]').getAttribute('content'); + const sentryTraceMetaTags = await page.locator('meta[name="sentry-trace"]').count(); + expect(sentryTraceMetaTags).toBe(0); - const [metaTraceId, metaParentSpanId, metaSampled] = sentryTraceMetaTagContent?.split('-') || []; + const baggageMetaTags = await page.locator('meta[name="baggage"]').count(); + expect(baggageMetaTags).toBe(0); expect(clientPageloadTraceId).toMatch(/[a-f0-9]{32}/); - expect(clientPageloadParentSpanId).toMatch(/[a-f0-9]{16}/); - expect(metaSampled).toBe('1'); + expect(clientPageloadParentSpanId).toBeUndefined(); expect(clientPageloadTxn).toMatchObject({ contexts: { @@ -40,9 +40,8 @@ test.describe('tracing in static/pre-rendered routes', () => { }), op: 'pageload', origin: 'auto.pageload.astro', - parent_span_id: metaParentSpanId, span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: metaTraceId, + trace_id: expect.stringMatching(/[a-f0-9]{32}/), }, }, platform: 'javascript', @@ -53,9 +52,6 @@ test.describe('tracing in static/pre-rendered routes', () => { type: 'transaction', }); - expect(baggageMetaTagContent).toContain('sentry-transaction=GET%20%2Ftest-static'); // URL-encoded for 'GET /test-static' - expect(baggageMetaTagContent).toContain('sentry-sampled=true'); - await page.waitForTimeout(1000); // wait another sec to ensure no server transaction is sent }); }); diff --git a/dev-packages/e2e-tests/test-applications/astro-5/package.json b/dev-packages/e2e-tests/test-applications/astro-5/package.json index 1c669cbc041b..6695d3c9434c 100644 --- a/dev-packages/e2e-tests/test-applications/astro-5/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-5/package.json @@ -7,6 +7,7 @@ "build": "astro build", "preview": "astro preview", "astro": "astro", + "start": "node ./dist/server/entry.mjs", "test:build": "pnpm install && pnpm build", "test:assert": "TEST_ENV=production playwright test" }, diff --git a/dev-packages/e2e-tests/test-applications/astro-5/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/astro-5/playwright.config.mjs index cd6ed611fb4a..ae58e4ff3ddc 100644 --- a/dev-packages/e2e-tests/test-applications/astro-5/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/astro-5/playwright.config.mjs @@ -7,7 +7,7 @@ if (!testEnv) { } const config = getPlaywrightConfig({ - startCommand: 'node ./dist/server/entry.mjs', + startCommand: 'pnpm start', }); export default config; diff --git a/dev-packages/e2e-tests/test-applications/astro-5/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/astro-5/tests/errors.client.test.ts index 19e9051ddf69..09ef627bf56c 100644 --- a/dev-packages/e2e-tests/test-applications/astro-5/tests/errors.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-5/tests/errors.client.test.ts @@ -70,7 +70,6 @@ test.describe('client-side errors', () => { contexts: { trace: { trace_id: expect.stringMatching(/[a-f0-9]{32}/), - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), }, }, diff --git a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.serverIslands.test.ts b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.serverIslands.test.ts index 202051e7a57e..c7496d4e6247 100644 --- a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.serverIslands.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.serverIslands.test.ts @@ -4,28 +4,27 @@ import { waitForTransaction } from '@sentry-internal/test-utils'; test.describe('tracing in static routes with server islands', () => { test('only sends client pageload transaction and server island endpoint transaction', async ({ page }) => { const clientPageloadTxnPromise = waitForTransaction('astro-5', txnEvent => { - return txnEvent?.transaction === '/server-island'; + return txnEvent.transaction === '/server-island'; }); const serverIslandEndpointTxnPromise = waitForTransaction('astro-5', evt => { - return !!evt.transaction?.startsWith('GET /_server-islands'); + return evt.transaction === 'GET /_server-islands/[name]'; }); await page.goto('/server-island'); const clientPageloadTxn = await clientPageloadTxnPromise; - const clientPageloadTraceId = clientPageloadTxn.contexts?.trace?.trace_id; const clientPageloadParentSpanId = clientPageloadTxn.contexts?.trace?.parent_span_id; - const sentryTraceMetaTagContent = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); - const baggageMetaTagContent = await page.locator('meta[name="baggage"]').getAttribute('content'); + const sentryTraceMetaTags = await page.locator('meta[name="sentry-trace"]').count(); + expect(sentryTraceMetaTags).toBe(0); - const [metaTraceId, metaParentSpanId, metaSampled] = sentryTraceMetaTagContent?.split('-') || []; + const baggageMetaTags = await page.locator('meta[name="baggage"]').count(); + expect(baggageMetaTags).toBe(0); expect(clientPageloadTraceId).toMatch(/[a-f0-9]{32}/); - expect(clientPageloadParentSpanId).toMatch(/[a-f0-9]{16}/); - expect(metaSampled).toBe('1'); + expect(clientPageloadParentSpanId).toBeUndefined(); expect(clientPageloadTxn).toMatchObject({ contexts: { @@ -37,9 +36,8 @@ test.describe('tracing in static routes with server islands', () => { }), op: 'pageload', origin: 'auto.pageload.astro', - parent_span_id: metaParentSpanId, span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: metaTraceId, + trace_id: clientPageloadTraceId, }, }, platform: 'javascript', @@ -63,9 +61,6 @@ test.describe('tracing in static routes with server islands', () => { ]), ); - expect(baggageMetaTagContent).toContain('sentry-transaction=GET%20%2Fserver-island'); // URL-encoded for 'GET /server-island' - expect(baggageMetaTagContent).toContain('sentry-sampled=true'); - const serverIslandEndpointTxn = await serverIslandEndpointTxnPromise; expect(serverIslandEndpointTxn).toMatchObject({ diff --git a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.static.test.ts b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.static.test.ts index 7593d6823d9b..4563dc2f306d 100644 --- a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.static.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.static.test.ts @@ -21,14 +21,14 @@ test.describe('tracing in static/pre-rendered routes', () => { const clientPageloadTraceId = clientPageloadTxn.contexts?.trace?.trace_id; const clientPageloadParentSpanId = clientPageloadTxn.contexts?.trace?.parent_span_id; - const sentryTraceMetaTagContent = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); - const baggageMetaTagContent = await page.locator('meta[name="baggage"]').getAttribute('content'); + const sentryTraceMetaTags = await page.locator('meta[name="sentry-trace"]').count(); + expect(sentryTraceMetaTags).toBe(0); - const [metaTraceId, metaParentSpanId, metaSampled] = sentryTraceMetaTagContent?.split('-') || []; + const baggageMetaTags = await page.locator('meta[name="baggage"]').count(); + expect(baggageMetaTags).toBe(0); expect(clientPageloadTraceId).toMatch(/[a-f0-9]{32}/); - expect(clientPageloadParentSpanId).toMatch(/[a-f0-9]{16}/); - expect(metaSampled).toBe('1'); + expect(clientPageloadParentSpanId).toBeUndefined(); expect(clientPageloadTxn).toMatchObject({ contexts: { @@ -40,9 +40,8 @@ test.describe('tracing in static/pre-rendered routes', () => { }), op: 'pageload', origin: 'auto.pageload.astro', - parent_span_id: metaParentSpanId, span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: metaTraceId, + trace_id: expect.stringMatching(/[a-f0-9]{32}/), }, }, platform: 'javascript', @@ -53,9 +52,6 @@ test.describe('tracing in static/pre-rendered routes', () => { type: 'transaction', }); - expect(baggageMetaTagContent).toContain('sentry-transaction=GET%20%2Ftest-static'); // URL-encoded for 'GET /test-static' - expect(baggageMetaTagContent).toContain('sentry-sampled=true'); - await page.waitForTimeout(1000); // wait another sec to ensure no server transaction is sent }); }); diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index fbf6720c23b8..e80020ba0913 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -1,9 +1,13 @@ -import type { RequestEventData, Scope, SpanAttributes } from '@sentry/core'; +/* eslint-disable max-lines */ +import type { Span, SpanAttributes } from '@sentry/core'; import { addNonEnumerableProperty, - extractQueryParamsFromUrl, flushIfServerless, + getIsolationScope, + getRootSpan, objectify, + SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, + spanToJSON, stripUrlQueryAndFragment, winterCGRequestToRequestData, } from '@sentry/core'; @@ -66,23 +70,60 @@ export const handleRequest: (options?: MiddlewareOptions) => MiddlewareResponseH }; return async (ctx, next) => { - // if there is an active span, we know that this handle call is nested and hence - // we don't create a new domain for it. If we created one, nested server calls would - // create new transactions instead of adding a child span to the currently active span. - if (getActiveSpan()) { - return instrumentRequest(ctx, next, handlerOptions); + // If no Sentry client exists, just bail + // Apart from the case when no Sentry.init() is called at all, this also happens + // if a prerendered page is hit first before a ssr page is called + // For regular prerendered pages, this is fine as we do not want to instrument them at runtime anyhow + // BUT for server-islands requests on a static page, this can be problematic... + // TODO: Today, this leads to inconsistent behavior: If a prerendered page is hit first (before _any_ ssr page is called), + // Sentry.init() has not been called yet (as this is only injected in SSR pages), so server-island requests are not instrumented + // If any SSR route is hit before, the client will already be set up and everything will work as expected :O + // To reproduce this: Run the astro-5 "tracing.serverIslands.test" only + if (!getClient()) { + return next(); } - return withIsolationScope(isolationScope => { - return instrumentRequest(ctx, next, handlerOptions, isolationScope); - }); + + const isDynamicPageRequest = checkIsDynamicPageRequest(ctx); + + // For static (prerendered) routes, we only want to inject the parametrized route meta tags + if (!isDynamicPageRequest) { + return handleStaticRoute(ctx, next); + } + + const activeSpan = getActiveSpan(); + const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; + + // if there is an active span, we just want to enhance it with routing data etc. + if (rootSpan && spanToJSON(rootSpan).op === 'http.server') { + return enhanceHttpServerSpan(ctx, next, rootSpan); + } + + return instrumentRequestStartHttpServerSpan(ctx, next, handlerOptions); }; }; -async function instrumentRequest( +async function handleStaticRoute( ctx: Parameters[0], next: Parameters[1], - options: MiddlewareOptions, - isolationScope?: Scope, +): Promise { + const parametrizedRoute = getParametrizedRoute(ctx); + try { + const originalResponse = await next(); + + // We never want to continue a trace here, so we do not inject trace data + // But we do want to inject the parametrized route, as this is used for client-side route parametrization + const metaTagsStr = getMetaTagsStr({ injectTraceData: false, parametrizedRoute }); + return injectMetaTagsInResponse(originalResponse, metaTagsStr); + } catch (e) { + sendErrorToSentry(e); + throw e; + } +} + +async function enhanceHttpServerSpan( + ctx: Parameters[0], + next: Parameters[1], + rootSpan: Span, ): Promise { // Make sure we don't accidentally double wrap (e.g. user added middleware and integration auto added it) const locals = ctx.locals as AstroLocalsWithSentry | undefined; @@ -93,179 +134,169 @@ async function instrumentRequest( addNonEnumerableProperty(locals, '__sentry_wrapped__', true); } - const isDynamicPageRequest = checkIsDynamicPageRequest(ctx); - const request = ctx.request; + const isolationScope = getIsolationScope(); + const method = request.method; - const { method, headers } = isDynamicPageRequest - ? request - : // headers can only be accessed in dynamic routes. Accessing `request.headers` in a static route - // will make the server log a warning. - { method: request.method, headers: undefined }; + try { + const parametrizedRoute = getParametrizedRoute(ctx); - return continueTrace( - { - sentryTrace: headers?.get('sentry-trace') || undefined, - baggage: headers?.get('baggage'), - }, - async () => { - getCurrentScope().setSDKProcessingMetadata({ - // We store the request on the current scope, not isolation scope, - // because we may have multiple requests nested inside each other - normalizedRequest: (isDynamicPageRequest - ? winterCGRequestToRequestData(request) - : { - method, - url: request.url, - query_string: extractQueryParamsFromUrl(request.url), - }) satisfies RequestEventData, + rootSpan.setAttributes({ + // This is here for backwards compatibility, we used to set this here before + method, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.astro', + }); + + if (parametrizedRoute) { + rootSpan.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'http.route': parametrizedRoute, }); - if (options.trackClientIp && isDynamicPageRequest) { - getCurrentScope().setUser({ ip_address: ctx.clientAddress }); - } - - try { - // `routePattern` is available after Astro 5 - const contextWithRoutePattern = ctx as Parameters[0] & { routePattern?: string }; - const rawRoutePattern = contextWithRoutePattern.routePattern; - - // @ts-expect-error Implicit any on Symbol.for (This is available in Astro 5) - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const routesFromManifest = ctx?.[Symbol.for('context.routes')]?.manifest?.routes; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const matchedRouteSegmentsFromManifest = routesFromManifest?.find( - (route: { routeData?: { route?: string } }) => route?.routeData?.route === rawRoutePattern, - )?.routeData?.segments; - - const parametrizedRoute = - // Astro v5 - Joining the segments to get the correct casing of the parametrized route - (matchedRouteSegmentsFromManifest && joinRouteSegments(matchedRouteSegmentsFromManifest)) || - // Fallback (Astro v4 and earlier) - interpolateRouteFromUrlAndParams(ctx.url.pathname, ctx.params); - - const source = parametrizedRoute ? 'route' : 'url'; - // storing res in a variable instead of directly returning is necessary to - // invoke the catch block if next() throws - - const attributes: SpanAttributes = { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.astro', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, - method, - url: stripUrlQueryAndFragment(ctx.url.href), - }; - - if (ctx.url.search) { - attributes['http.query'] = ctx.url.search; - } + isolationScope.setTransactionName(`${method} ${parametrizedRoute}`); + } - if (ctx.url.hash) { - attributes['http.fragment'] = ctx.url.hash; - } + try { + const originalResponse = await next(); + const metaTagsStr = getMetaTagsStr({ injectTraceData: true, parametrizedRoute }); + return injectMetaTagsInResponse(originalResponse, metaTagsStr); + } catch (e) { + sendErrorToSentry(e); + throw e; + } + } finally { + await flushIfServerless(); + } +} - isolationScope?.setTransactionName(`${method} ${parametrizedRoute || ctx.url.pathname}`); - - const res = await startSpan( - { - attributes, - name: `${method} ${parametrizedRoute || ctx.url.pathname}`, - op: 'http.server', - }, - async span => { - try { - const originalResponse = await next(); - if (originalResponse.status) { - setHttpStatus(span, originalResponse.status); - } +async function instrumentRequestStartHttpServerSpan( + ctx: Parameters[0], + next: Parameters[1], + options: MiddlewareOptions, +): Promise { + // Make sure we don't accidentally double wrap (e.g. user added middleware and integration auto added it) + const locals = ctx.locals as AstroLocalsWithSentry | undefined; + if (locals?.__sentry_wrapped__) { + return next(); + } + if (locals) { + addNonEnumerableProperty(locals, '__sentry_wrapped__', true); + } - const client = getClient(); - const contentType = originalResponse.headers.get('content-type'); + const request = ctx.request; - const isPageloadRequest = contentType?.startsWith('text/html'); - if (!isPageloadRequest || !client) { - return originalResponse; - } + // Note: We guard outside of this function call that the request is dynamic + // accessing headers on a static route would throw + const { method, headers } = request; - // Type case necessary b/c the body's ReadableStream type doesn't include - // the async iterator that is actually available in Node - // We later on use the async iterator to read the body chunks - // see https://github.com/microsoft/TypeScript/issues/39051 - const originalBody = originalResponse.body as NodeJS.ReadableStream | null; - if (!originalBody) { - return originalResponse; - } + return withIsolationScope(isolationScope => { + return continueTrace( + { + sentryTrace: headers?.get('sentry-trace') || undefined, + baggage: headers?.get('baggage'), + }, + async () => { + getCurrentScope().setSDKProcessingMetadata({ + // We store the request on the current scope, not isolation scope, + // because we may have multiple requests nested inside each other + normalizedRequest: winterCGRequestToRequestData(request), + }); + + if (options.trackClientIp) { + isolationScope.setUser({ ip_address: ctx.clientAddress }); + } - const decoder = new TextDecoder(); - - const newResponseStream = new ReadableStream({ - start: async controller => { - // Assign to a new variable to avoid TS losing the narrower type checked above. - const body = originalBody; - - async function* bodyReporter(): AsyncGenerator { - try { - for await (const chunk of body) { - yield chunk; - } - } catch (e) { - // Report stream errors coming from user code or Astro rendering. - sendErrorToSentry(e); - throw e; - } - } - - try { - for await (const chunk of bodyReporter()) { - const html = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true }); - const modifiedHtml = addMetaTagToHead(html, parametrizedRoute); - controller.enqueue(new TextEncoder().encode(modifiedHtml)); - } - } catch (e) { - controller.error(e); - } finally { - controller.close(); - } - }, - }); - - return new Response(newResponseStream, originalResponse); - } catch (e) { - sendErrorToSentry(e); - throw e; - } - }, - ); - return res; - } finally { - await flushIfServerless(); - } - // TODO: flush if serverless (first extract function) - }, - ); + try { + const parametrizedRoute = getParametrizedRoute(ctx); + + const source = parametrizedRoute ? 'route' : 'url'; + // storing res in a variable instead of directly returning is necessary to + // invoke the catch block if next() throws + + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.astro', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, + [SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: method, + // This is here for backwards compatibility, we used to set this here before + method, + url: stripUrlQueryAndFragment(ctx.url.href), + }; + + if (parametrizedRoute) { + attributes['http.route'] = parametrizedRoute; + } + + if (ctx.url.search) { + attributes['http.query'] = ctx.url.search; + } + + if (ctx.url.hash) { + attributes['http.fragment'] = ctx.url.hash; + } + + isolationScope.setTransactionName(`${method} ${parametrizedRoute || ctx.url.pathname}`); + + const res = await startSpan( + { + attributes, + name: `${method} ${parametrizedRoute || ctx.url.pathname}`, + op: 'http.server', + }, + async span => { + try { + const originalResponse = await next(); + if (originalResponse.status) { + setHttpStatus(span, originalResponse.status); + } + + const metaTagsStr = getMetaTagsStr({ injectTraceData: true, parametrizedRoute }); + return injectMetaTagsInResponse(originalResponse, metaTagsStr); + } catch (e) { + sendErrorToSentry(e); + throw e; + } + }, + ); + return res; + } finally { + await flushIfServerless(); + } + // TODO: flush if serverless (first extract function) + }, + ); + }); } /** * This function optimistically assumes that the HTML coming in chunks will not be split * within the tag. If this still happens, we simply won't replace anything. */ -function addMetaTagToHead(htmlChunk: string, parametrizedRoute?: string): string { - if (typeof htmlChunk !== 'string') { +function addMetaTagToHead(htmlChunk: string, metaTagsStr: string): string { + if (typeof htmlChunk !== 'string' || !metaTagsStr) { return htmlChunk; } - const metaTags = parametrizedRoute - ? `${getTraceMetaTags()}\n\n` - : getTraceMetaTags(); - - if (!metaTags) { - return htmlChunk; - } - - const content = `${metaTags}`; + const content = `${metaTagsStr}`; return htmlChunk.replace('', content); } +function getMetaTagsStr({ + injectTraceData, + parametrizedRoute, +}: { + injectTraceData: boolean; + parametrizedRoute: string | undefined; +}): string { + const parts = []; + if (injectTraceData) { + parts.push(getTraceMetaTags()); + } + if (parametrizedRoute) { + parts.push(``); + } + return parts.join('\n'); +} + /** * Interpolates the route from the URL and the passed params. * Best we can do to get a route name instead of a raw URL. @@ -376,3 +407,89 @@ function joinRouteSegments(segments: RoutePart[][]): string { return `/${parthArray.join('/')}`; } + +function getParametrizedRoute( + ctx: Parameters[0] & { routePattern?: string }, +): string | undefined { + try { + // `routePattern` is available after Astro 5 + const contextWithRoutePattern = ctx; + const rawRoutePattern = contextWithRoutePattern.routePattern; + + // @ts-expect-error Implicit any on Symbol.for (This is available in Astro 5) + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const routesFromManifest = ctx?.[Symbol.for('context.routes')]?.manifest?.routes; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const matchedRouteSegmentsFromManifest = routesFromManifest?.find( + (route: { routeData?: { route?: string } }) => route?.routeData?.route === rawRoutePattern, + )?.routeData?.segments; + + return ( + // Astro v5 - Joining the segments to get the correct casing of the parametrized route + (matchedRouteSegmentsFromManifest && joinRouteSegments(matchedRouteSegmentsFromManifest)) || + // Fallback (Astro v4 and earlier) + interpolateRouteFromUrlAndParams(ctx.url.pathname, ctx.params) + ); + } catch { + return undefined; + } +} + +function injectMetaTagsInResponse(originalResponse: Response, metaTagsStr: string): Response { + try { + const contentType = originalResponse.headers.get('content-type'); + + const isPageloadRequest = contentType?.startsWith('text/html'); + if (!isPageloadRequest) { + return originalResponse; + } + + // Type case necessary b/c the body's ReadableStream type doesn't include + // the async iterator that is actually available in Node + // We later on use the async iterator to read the body chunks + // see https://github.com/microsoft/TypeScript/issues/39051 + const originalBody = originalResponse.body as NodeJS.ReadableStream | null; + if (!originalBody) { + return originalResponse; + } + + const decoder = new TextDecoder(); + + const newResponseStream = new ReadableStream({ + start: async controller => { + // Assign to a new variable to avoid TS losing the narrower type checked above. + const body = originalBody; + + async function* bodyReporter(): AsyncGenerator { + try { + for await (const chunk of body) { + yield chunk; + } + } catch (e) { + // Report stream errors coming from user code or Astro rendering. + sendErrorToSentry(e); + throw e; + } + } + + try { + for await (const chunk of bodyReporter()) { + const html = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true }); + const modifiedHtml = addMetaTagToHead(html, metaTagsStr); + controller.enqueue(new TextEncoder().encode(modifiedHtml)); + } + } catch (e) { + controller.error(e); + } finally { + controller.close(); + } + }, + }); + + return new Response(newResponseStream, originalResponse); + } catch (e) { + sendErrorToSentry(e); + throw e; + } +} diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index 6430a5f47eb7..13e54d1537cb 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -5,26 +5,48 @@ import * as SentryNode from '@sentry/node'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { handleRequest, interpolateRouteFromUrlAndParams } from '../../src/server/middleware'; -vi.mock('../../src/server/meta', () => ({ - getTracingMetaTagValues: () => ({ - sentryTrace: '', - baggage: '', - }), -})); +const DYNAMIC_REQUEST_CONTEXT = { + clientAddress: '192.168.0.1', + request: { + method: 'GET', + url: '/users', + headers: new Headers(), + }, + params: {}, + url: new URL('https://myDomain.io/users/'), +}; + +const STATIC_REQUEST_CONTEXT = { + request: { + method: 'GET', + url: '/users', + headers: new Headers({ + 'some-header': 'some-value', + }), + }, + get clientAddress() { + throw new Error('clientAddress.get() should not be called in static page requests'); + }, + params: {}, + url: new URL('https://myDomain.io/users/'), +}; describe('sentryMiddleware', () => { const startSpanSpy = vi.spyOn(SentryNode, 'startSpan'); const getSpanMock = vi.fn(() => { - return {} as Span | undefined; + return { + spanContext: () => ({ + spanId: '123', + traceId: '123', + }), + } as Span | undefined; }); - const setUserMock = vi.fn(); const setSDKProcessingMetadataMock = vi.fn(); beforeEach(() => { vi.spyOn(SentryNode, 'getCurrentScope').mockImplementation(() => { return { - setUser: setUserMock, setPropagationContext: vi.fn(), getSpan: getSpanMock, setSDKProcessingMetadata: setSDKProcessingMetadataMock, @@ -42,6 +64,9 @@ describe('sentryMiddleware', () => { vi.spyOn(SentryCore, 'getDynamicSamplingContextFromSpan').mockImplementation(() => ({ transaction: 'test', })); + + // Ensure this is wiped + SentryCore.setUser(null); }); const nextResult = Promise.resolve(new Response(null, { status: 200, headers: new Headers() })); @@ -53,6 +78,7 @@ describe('sentryMiddleware', () => { it('creates a span for an incoming request', async () => { const middleware = handleRequest(); const ctx = { + ...DYNAMIC_REQUEST_CONTEXT, request: { method: 'GET', url: '/users/123/details', @@ -75,6 +101,8 @@ describe('sentryMiddleware', () => { method: 'GET', url: 'https://mydomain.io/users/123/details', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SentryCore.SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: 'GET', + 'http.route': '/users/[id]/details', }, name: 'GET /users/[id]/details', op: 'http.server', @@ -89,6 +117,7 @@ describe('sentryMiddleware', () => { it("sets source route if the url couldn't be decoded correctly", async () => { const middleware = handleRequest(); const ctx = { + ...DYNAMIC_REQUEST_CONTEXT, request: { method: 'GET', url: '/a%xx', @@ -109,6 +138,7 @@ describe('sentryMiddleware', () => { method: 'GET', url: 'http://localhost:1234/a%xx', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SentryCore.SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: 'GET', }, name: 'GET a%xx', op: 'http.server', @@ -125,13 +155,7 @@ describe('sentryMiddleware', () => { const middleware = handleRequest(); const ctx = { - request: { - method: 'GET', - url: '/users', - headers: new Headers(), - }, - url: new URL('https://myDomain.io/users/'), - params: {}, + ...DYNAMIC_REQUEST_CONTEXT, }; const error = new Error('Something went wrong'); @@ -153,13 +177,7 @@ describe('sentryMiddleware', () => { const middleware = handleRequest(); const ctx = { - request: { - method: 'GET', - url: '/users', - headers: new Headers(), - }, - url: new URL('https://myDomain.io/users/'), - params: {}, + ...DYNAMIC_REQUEST_CONTEXT, }; const error = new Error('Something went wrong'); @@ -195,50 +213,31 @@ describe('sentryMiddleware', () => { it('attaches client IP if `trackClientIp=true` when handling dynamic page requests', async () => { const middleware = handleRequest({ trackClientIp: true }); const ctx = { - request: { - method: 'GET', - url: '/users', - headers: new Headers({ - 'some-header': 'some-value', - }), - }, - clientAddress: '192.168.0.1', - params: {}, - url: new URL('https://myDomain.io/users/'), + ...DYNAMIC_REQUEST_CONTEXT, }; - const next = vi.fn(() => nextResult); // @ts-expect-error, a partial ctx object is fine here - await middleware(ctx, next); - - expect(setUserMock).toHaveBeenCalledWith({ ip_address: '192.168.0.1' }); + await middleware(ctx, async () => { + expect(SentryCore.getIsolationScope().getScopeData().user).toEqual({ ip_address: '192.168.0.1' }); + return nextResult; + }); }); it("doesn't attach a client IP if `trackClientIp=true` when handling static page requests", async () => { const middleware = handleRequest({ trackClientIp: true }); - const ctx = { - request: { - method: 'GET', - url: '/users', - headers: new Headers({ - 'some-header': 'some-value', - }), - }, - get clientAddress() { - throw new Error('clientAddress.get() should not be called in static page requests'); - }, - params: {}, - url: new URL('https://myDomain.io/users/'), - }; - - const next = vi.fn(() => nextResult); + const ctx = STATIC_REQUEST_CONTEXT; // @ts-expect-error, a partial ctx object is fine here - await middleware(ctx, next); - - expect(setUserMock).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledTimes(1); + await middleware(ctx, async () => { + expect(SentryCore.getIsolationScope().getScopeData().user).toEqual({ + email: undefined, + id: undefined, + ip_address: undefined, + username: undefined, + }); + return nextResult; + }); }); }); @@ -246,6 +245,7 @@ describe('sentryMiddleware', () => { it('attaches request as SDK processing metadata in dynamic page requests', async () => { const middleware = handleRequest({}); const ctx = { + ...DYNAMIC_REQUEST_CONTEXT, request: { method: 'GET', url: '/users', @@ -253,9 +253,6 @@ describe('sentryMiddleware', () => { 'some-header': 'some-value', }), }, - clientAddress: '192.168.0.1', - params: {}, - url: new URL('https://myDomain.io/users/'), }; const next = vi.fn(() => nextResult); @@ -276,46 +273,52 @@ describe('sentryMiddleware', () => { it("doesn't attach request headers as processing metadata for static page requests", async () => { const middleware = handleRequest({}); - const ctx = { - request: { - method: 'GET', - url: '/users', - headers: new Headers({ - 'some-header': 'some-value', - }), - }, - get clientAddress() { - throw new Error('clientAddress.get() should not be called in static page requests'); - }, - params: {}, - url: new URL('https://myDomain.io/users/'), - }; + const ctx = STATIC_REQUEST_CONTEXT; const next = vi.fn(() => nextResult); // @ts-expect-error, a partial ctx object is fine here await middleware(ctx, next); - expect(setSDKProcessingMetadataMock).toHaveBeenCalledWith({ - normalizedRequest: { - method: 'GET', - url: '/users', - }, - }); + expect(setSDKProcessingMetadataMock).not.toHaveBeenCalled(); expect(next).toHaveBeenCalledTimes(1); }); }); - it('injects tracing tags into the HTML of a pageload response', async () => { + it('does not inject tracing tags if route is static', async () => { + const middleware = handleRequest(); + + const ctx = STATIC_REQUEST_CONTEXT; + const next = vi.fn(() => + Promise.resolve( + new Response('', { + headers: new Headers({ 'content-type': 'text/html' }), + }), + ), + ); + + // @ts-expect-error, a partial ctx object is fine here + const resultFromNext = await middleware(ctx, next); + + expect(resultFromNext?.headers.get('content-type')).toEqual('text/html'); + + const html = await resultFromNext?.text(); + + expect(html).toContain(''); + expect(html).toContain(''); + // parametrized route is injected + expect(html).toContain(''); + // trace data is not injected + expect(html).not.toContain(''); + expect(html).not.toContain(''); + expect(html).not.toContain('', { + headers: new Headers({ 'content-type': 'text/html' }), + }), + ), + ); + + // @ts-expect-error, a partial ctx object is fine here + const resultFromNext = await middleware(ctx, next); + + expect(resultFromNext?.headers.get('content-type')).toEqual('text/html'); + + const html = await resultFromNext?.text(); + + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); expect(html).toContain(' { const middleware = handleRequest(); const ctx = { - request: { - method: 'GET', - url: '/users', - headers: new Headers(), - }, - params: {}, - url: new URL('https://myDomain.io/users/'), + ...DYNAMIC_REQUEST_CONTEXT, }; const originalHtml = '

no head

'; @@ -399,7 +421,9 @@ describe('sentryMiddleware', () => { it('starts a new async context if no span is active', async () => { getSpanMock.mockReturnValueOnce(undefined); const handler = handleRequest(); - const ctx = {}; + const ctx = { + ...DYNAMIC_REQUEST_CONTEXT, + }; const next = vi.fn(); try { @@ -413,11 +437,24 @@ describe('sentryMiddleware', () => { }); it("doesn't start a new async context if a span is active", async () => { - // @ts-expect-error, a empty span is fine here - getSpanMock.mockReturnValueOnce({}); + getSpanMock.mockReturnValueOnce({ + spanContext: () => ({ + spanId: '123', + traceId: '123', + traceFlags: 1, + }), + // @ts-expect-error, this is fine + getSpanJSON: () => ({ + span_id: '123', + trace_id: '123', + op: 'http.server', + }), + }); const handler = handleRequest(); - const ctx = {}; + const ctx = { + ...DYNAMIC_REQUEST_CONTEXT, + }; const next = vi.fn(); try { From 47451774367d7bbed923a5975af4abfbe69d85c1 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 8 Sep 2025 10:17:36 +0200 Subject: [PATCH 12/19] ref(core): Add `mechanism.type` to `trpcMiddleware` errors (#17287) ref #17252 closes #17212 --- packages/core/src/trpc.ts | 2 +- packages/core/test/lib/trpc.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/trpc.ts b/packages/core/src/trpc.ts index beac8c5b4c4c..3a661ca90a3d 100644 --- a/packages/core/src/trpc.ts +++ b/packages/core/src/trpc.ts @@ -19,7 +19,7 @@ export interface SentryTrpcMiddlewareArguments { getRawInput?: () => Promise; } -const trpcCaptureContext = { mechanism: { handled: false, data: { function: 'trpcMiddleware' } } }; +const trpcCaptureContext = { mechanism: { handled: false, type: 'auto.rpc.trpc.middleware' } }; function captureIfError(nextResult: unknown): void { // TODO: Set span status based on what TRPCError was encountered diff --git a/packages/core/test/lib/trpc.test.ts b/packages/core/test/lib/trpc.test.ts index c3eca8cf4954..f67d1e53bdfd 100644 --- a/packages/core/test/lib/trpc.test.ts +++ b/packages/core/test/lib/trpc.test.ts @@ -78,7 +78,7 @@ describe('trpcMiddleware', () => { }); expect(exports.captureException).toHaveBeenCalledWith(error, { - mechanism: { handled: false, data: { function: 'trpcMiddleware' } }, + mechanism: { handled: false, type: 'auto.rpc.trpc.middleware' }, }); }); @@ -115,7 +115,7 @@ describe('trpcMiddleware', () => { ).rejects.toThrow(error); expect(exports.captureException).toHaveBeenCalledWith(error, { - mechanism: { handled: false, data: { function: 'trpcMiddleware' } }, + mechanism: { handled: false, type: 'auto.rpc.trpc.middleware' }, }); }); From dcb4d23690af9ee2fb10c484c6f76bed4e555751 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 8 Sep 2025 10:56:45 +0200 Subject: [PATCH 13/19] ci: Fix running of only changed E2E tests (#17551) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Noticed here: https://github.com/getsentry/sentry-javascript/actions/runs/17542916888/job/49818463117 that this was not actually working 🤔 I played around a bit with this locally, and this change made it work for me. Not sure why the `path` part is not working as expected, but we filter for the correct changes anyhow below, so this should be fine IMHO. --- dev-packages/e2e-tests/lib/getTestMatrix.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-packages/e2e-tests/lib/getTestMatrix.ts b/dev-packages/e2e-tests/lib/getTestMatrix.ts index 07ba73b8b6f2..1261e7d5b3ac 100644 --- a/dev-packages/e2e-tests/lib/getTestMatrix.ts +++ b/dev-packages/e2e-tests/lib/getTestMatrix.ts @@ -46,7 +46,7 @@ function run(): void { }, }); - const { base, head, optional } = values; + const { base, head = 'HEAD', optional } = values; const testApplications = globSync('*/package.json', { cwd: `${__dirname}/../test-applications`, @@ -174,7 +174,7 @@ function getAffectedTestApplications( } function getChangedTestApps(base: string, head?: string): false | Set { - const changedFiles = execSync(`git diff --name-only ${base}${head ? `..${head}` : ''} -- dev-packages/e2e-tests/`, { + const changedFiles = execSync(`git diff --name-only ${base}${head ? `..${head}` : ''} -- .`, { encoding: 'utf-8', }) .toString() From 9bd421b4c1cdd8a625d34e7bc703f7beb955b660 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 8 Sep 2025 15:57:31 +0200 Subject: [PATCH 14/19] ci: Check for stable lockfile (#17552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It keeps happening that we have an out-of-date yarn.lock file, this should hopefully lint against this for the future 🤔 Failing here: https://github.com/getsentry/sentry-javascript/actions/runs/17545403486/job/49825686877?pr=17552 --- .github/workflows/build.yml | 3 + yarn.lock | 107 +++++++++++++++++++----------------- 2 files changed, 59 insertions(+), 51 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d6b5c3952b72..4066a18eefe2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -315,6 +315,9 @@ jobs: - name: Lint for ES compatibility run: yarn lint:es-compatibility + - name: Check that yarn.lock is stable + run: yarn && git diff --exit-code yarn.lock + job_check_format: name: Check file formatting needs: [job_get_metadata] diff --git a/yarn.lock b/yarn.lock index d22046031f12..a9f6f22ae5e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1576,12 +1576,12 @@ dependencies: "@babel/types" "^7.26.9" -"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.16.4", "@babel/parser@^7.18.10", "@babel/parser@^7.20.7", "@babel/parser@^7.21.8", "@babel/parser@^7.22.10", "@babel/parser@^7.22.16", "@babel/parser@^7.22.5", "@babel/parser@^7.23.5", "@babel/parser@^7.23.6", "@babel/parser@^7.23.9", "@babel/parser@^7.25.3", "@babel/parser@^7.25.4", "@babel/parser@^7.25.6", "@babel/parser@^7.26.7", "@babel/parser@^7.27.2", "@babel/parser@^7.27.5", "@babel/parser@^7.27.7", "@babel/parser@^7.4.5", "@babel/parser@^7.7.0": - version "7.27.7" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.27.7.tgz#1687f5294b45039c159730e3b9c1f1b242e425e9" - integrity sha512-qnzXzDXdr/po3bOTbTIQZ7+TxNKxpkN5IifVLXS+r7qwynkZfPyjZfE7hCXbo7IoO9TNcSyibgONsf2HauUd3Q== +"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.16.4", "@babel/parser@^7.18.10", "@babel/parser@^7.20.7", "@babel/parser@^7.21.8", "@babel/parser@^7.22.10", "@babel/parser@^7.22.16", "@babel/parser@^7.22.5", "@babel/parser@^7.23.5", "@babel/parser@^7.23.6", "@babel/parser@^7.23.9", "@babel/parser@^7.25.3", "@babel/parser@^7.25.4", "@babel/parser@^7.25.6", "@babel/parser@^7.26.7", "@babel/parser@^7.27.2", "@babel/parser@^7.27.5", "@babel/parser@^7.27.7", "@babel/parser@^7.28.3", "@babel/parser@^7.4.5", "@babel/parser@^7.7.0": + version "7.28.4" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.4.tgz#da25d4643532890932cc03f7705fe19637e03fa8" + integrity sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg== dependencies: - "@babel/types" "^7.27.7" + "@babel/types" "^7.28.4" "@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.24.4": version "7.24.4" @@ -2644,10 +2644,10 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.27.1" -"@babel/types@^7.0.0", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.20.7", "@babel/types@^7.22.10", "@babel/types@^7.22.15", "@babel/types@^7.22.17", "@babel/types@^7.22.19", "@babel/types@^7.22.5", "@babel/types@^7.23.6", "@babel/types@^7.23.9", "@babel/types@^7.24.7", "@babel/types@^7.25.4", "@babel/types@^7.25.6", "@babel/types@^7.25.9", "@babel/types@^7.26.3", "@babel/types@^7.26.9", "@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.27.6", "@babel/types@^7.27.7", "@babel/types@^7.3.0", "@babel/types@^7.4.4", "@babel/types@^7.7.0", "@babel/types@^7.7.2": - version "7.27.7" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.27.7.tgz#40eabd562049b2ee1a205fa589e629f945dce20f" - integrity sha512-8OLQgDScAOHXnAz2cV+RfzzNMipuLVBz2biuAJFMV9bfkNf393je3VM8CLkjQodW5+iWsSJdSgSWT6rsZoXHPw== +"@babel/types@^7.0.0", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.20.7", "@babel/types@^7.22.10", "@babel/types@^7.22.15", "@babel/types@^7.22.17", "@babel/types@^7.22.19", "@babel/types@^7.22.5", "@babel/types@^7.23.6", "@babel/types@^7.23.9", "@babel/types@^7.24.7", "@babel/types@^7.25.4", "@babel/types@^7.25.6", "@babel/types@^7.25.9", "@babel/types@^7.26.3", "@babel/types@^7.26.9", "@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.27.6", "@babel/types@^7.27.7", "@babel/types@^7.28.4", "@babel/types@^7.3.0", "@babel/types@^7.4.4", "@babel/types@^7.7.0", "@babel/types@^7.7.2": + version "7.28.4" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.4.tgz#0a4e618f4c60a7cd6c11cb2d48060e4dbe38ac3a" + integrity sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q== dependencies: "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.27.1" @@ -4822,10 +4822,10 @@ "@jridgewell/gen-mapping" "^0.3.0" "@jridgewell/trace-mapping" "^0.3.9" -"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.4.15", "@jridgewell/sourcemap-codec@^1.5.0": - version "1.5.0" - resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" - integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== +"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.4.15", "@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== "@jridgewell/trace-mapping@0.3.9": version "0.3.9" @@ -9359,13 +9359,13 @@ estree-walker "^2.0.2" source-map "^0.6.1" -"@vue/compiler-core@3.5.17": - version "3.5.17" - resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.17.tgz#23d291bd01b863da3ef2e26e7db84d8e01a9b4c5" - integrity sha512-Xe+AittLbAyV0pabcN7cP7/BenRBNcteM4aSDCtRvGw0d9OL+HG1u/XHLY/kt1q4fyMeZYXyIYrsHuPSiDPosA== +"@vue/compiler-core@3.5.21": + version "3.5.21" + resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.21.tgz#5915b19273f0492336f0beb227aba86813e2c8a8" + integrity sha512-8i+LZ0vf6ZgII5Z9XmUvrCyEzocvWT+TeR2VBUVlzIH6Tyv57E20mPZ1bCS+tbejgUgmjrEh7q/0F0bibskAmw== dependencies: - "@babel/parser" "^7.27.5" - "@vue/shared" "3.5.17" + "@babel/parser" "^7.28.3" + "@vue/shared" "3.5.21" entities "^4.5.0" estree-walker "^2.0.2" source-map-js "^1.2.1" @@ -9389,13 +9389,13 @@ "@vue/compiler-core" "3.2.45" "@vue/shared" "3.2.45" -"@vue/compiler-dom@3.5.17", "@vue/compiler-dom@^3.3.4": - version "3.5.17" - resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.17.tgz#7bc19a20e23b670243a64b47ce3a890239b870be" - integrity sha512-+2UgfLKoaNLhgfhV5Ihnk6wB4ljyW1/7wUIog2puUqajiC29Lp5R/IKDdkebh9jTbTogTbsgB+OY9cEWzG95JQ== +"@vue/compiler-dom@3.5.21", "@vue/compiler-dom@^3.3.4": + version "3.5.21" + resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.21.tgz#26126447fe1e1d16c8cbac45b26e66b3f7175f65" + integrity sha512-jNtbu/u97wiyEBJlJ9kmdw7tAr5Vy0Aj5CgQmo+6pxWNQhXZDPsRr1UWPN4v3Zf82s2H3kF51IbzZ4jMWAgPlQ== dependencies: - "@vue/compiler-core" "3.5.17" - "@vue/shared" "3.5.17" + "@vue/compiler-core" "3.5.21" + "@vue/shared" "3.5.21" "@vue/compiler-dom@3.5.9": version "3.5.9" @@ -9436,18 +9436,18 @@ postcss "^8.4.47" source-map-js "^1.2.0" -"@vue/compiler-sfc@^3.4.15", "@vue/compiler-sfc@^3.5.4": - version "3.5.17" - resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.17.tgz#c518871276e26593612bdab36f3f5bcd053b13bf" - integrity sha512-rQQxbRJMgTqwRugtjw0cnyQv9cP4/4BxWfTdRBkqsTfLOHWykLzbOc3C4GGzAmdMDxhzU/1Ija5bTjMVrddqww== +"@vue/compiler-sfc@^3.4.15", "@vue/compiler-sfc@^3.5.13", "@vue/compiler-sfc@^3.5.4": + version "3.5.21" + resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.21.tgz#e48189ef3ffe334c864c2625389ebe3bb4fa41eb" + integrity sha512-SXlyk6I5eUGBd2v8Ie7tF6ADHE9kCR6mBEuPyH1nUZ0h6Xx6nZI29i12sJKQmzbDyr2tUHMhhTt51Z6blbkTTQ== dependencies: - "@babel/parser" "^7.27.5" - "@vue/compiler-core" "3.5.17" - "@vue/compiler-dom" "3.5.17" - "@vue/compiler-ssr" "3.5.17" - "@vue/shared" "3.5.17" + "@babel/parser" "^7.28.3" + "@vue/compiler-core" "3.5.21" + "@vue/compiler-dom" "3.5.21" + "@vue/compiler-ssr" "3.5.21" + "@vue/shared" "3.5.21" estree-walker "^2.0.2" - magic-string "^0.30.17" + magic-string "^0.30.18" postcss "^8.5.6" source-map-js "^1.2.1" @@ -9459,13 +9459,13 @@ "@vue/compiler-dom" "3.2.45" "@vue/shared" "3.2.45" -"@vue/compiler-ssr@3.5.17": - version "3.5.17" - resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.17.tgz#14ba3b7bba6e0e1fd02002316263165a5d1046c7" - integrity sha512-hkDbA0Q20ZzGgpj5uZjb9rBzQtIHLS78mMilwrlpWk2Ep37DYntUz0PonQ6kr113vfOEdM+zTBuJDaceNIW0tQ== +"@vue/compiler-ssr@3.5.21": + version "3.5.21" + resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.21.tgz#f351c27aa5c075faa609596b2269c53df0df3aa1" + integrity sha512-vKQ5olH5edFZdf5ZrlEgSO1j1DMA4u23TVK5XR1uMhvwnYvVdDF0nHXJUblL/GvzlShQbjhZZ2uvYmDlAbgo9w== dependencies: - "@vue/compiler-dom" "3.5.17" - "@vue/shared" "3.5.17" + "@vue/compiler-dom" "3.5.21" + "@vue/shared" "3.5.21" "@vue/compiler-ssr@3.5.9": version "3.5.9" @@ -9606,10 +9606,10 @@ resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.45.tgz#a3fffa7489eafff38d984e23d0236e230c818bc2" integrity sha512-Ewzq5Yhimg7pSztDV+RH1UDKBzmtqieXQlpTVm2AwraoRL/Rks96mvd8Vgi7Lj+h+TH8dv7mXD3FRZR3TUvbSg== -"@vue/shared@3.5.17", "@vue/shared@^3.5.5": - version "3.5.17" - resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.17.tgz#e8b3a41f0be76499882a89e8ed40d86a70fa4b70" - integrity sha512-CabR+UN630VnsJO/jHWYBC1YVXyMq94KKp6iF5MQgZJs5I8cmjw6oVMO1oDbtBkENSHSSn/UadWlW/OAgdmKrg== +"@vue/shared@3.5.21", "@vue/shared@^3.5.5": + version "3.5.21" + resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.21.tgz#505edb122629d1979f70a2a65ca0bd4050dc2e54" + integrity sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw== "@vue/shared@3.5.9": version "3.5.9" @@ -21226,12 +21226,12 @@ magic-string@^0.26.0, magic-string@^0.26.7: dependencies: sourcemap-codec "^1.4.8" -magic-string@^0.30.0, magic-string@^0.30.10, magic-string@^0.30.11, magic-string@^0.30.17, magic-string@^0.30.3, magic-string@^0.30.4, magic-string@^0.30.5, magic-string@^0.30.8, magic-string@~0.30.0: - version "0.30.17" - resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.17.tgz#450a449673d2460e5bbcfba9a61916a1714c7453" - integrity sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA== +magic-string@^0.30.0, magic-string@^0.30.10, magic-string@^0.30.11, magic-string@^0.30.17, magic-string@^0.30.18, magic-string@^0.30.3, magic-string@^0.30.4, magic-string@^0.30.5, magic-string@^0.30.8, magic-string@~0.30.0: + version "0.30.18" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.18.tgz#905bfbbc6aa5692703a93db26a9edcaa0007d2bb" + integrity sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ== dependencies: - "@jridgewell/sourcemap-codec" "^1.5.0" + "@jridgewell/sourcemap-codec" "^1.5.5" magicast@^0.2.10: version "0.2.11" @@ -23004,6 +23004,11 @@ node-cron@^3.0.3: dependencies: uuid "8.3.2" +node-domexception@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" + integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== + node-fetch-native@^1.4.0, node-fetch-native@^1.6.3, node-fetch-native@^1.6.4, node-fetch-native@^1.6.6: version "1.6.6" resolved "https://registry.yarnpkg.com/node-fetch-native/-/node-fetch-native-1.6.6.tgz#ae1d0e537af35c2c0b0de81cbff37eedd410aa37" @@ -31109,7 +31114,7 @@ web-namespaces@^2.0.0: resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-2.0.1.tgz#1010ff7c650eccb2592cebeeaf9a1b253fd40692" integrity sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ== -web-streams-polyfill@^3.1.1: +web-streams-polyfill@^3.0.3, web-streams-polyfill@^3.1.1: version "3.3.3" resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b" integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw== From f3f0ba31e7894158d0e9de85989a07cd61d39304 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Mon, 8 Sep 2025 15:20:45 -0400 Subject: [PATCH 15/19] feat(core): Add replay id to logs (#17563) ref https://github.com/getsentry/sentry-docs/pull/14838 resolves https://linear.app/getsentry/issue/JS-901/add-sentryreplay-id-attribute-to-javascript-sdk-logs --- packages/core/src/logs/exports.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/core/src/logs/exports.ts b/packages/core/src/logs/exports.ts index 1a503fdb94ed..702e8605adf1 100644 --- a/packages/core/src/logs/exports.ts +++ b/packages/core/src/logs/exports.ts @@ -4,6 +4,7 @@ import { _getTraceInfoFromScope } from '../client'; import { getClient, getCurrentScope, getGlobalScope, getIsolationScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; import type { Scope, ScopeData } from '../scope'; +import type { Integration } from '../types-hoist/integration'; import type { Log, SerializedLog, SerializedLogAttributeValue } from '../types-hoist/log'; import { mergeScopeData } from '../utils/applyScopeDataToEvent'; import { consoleSandbox, debug } from '../utils/debug-logger'; @@ -150,6 +151,9 @@ export function _INTERNAL_captureLog( setLogAttribute(processedLogAttributes, 'sentry.sdk.name', name); setLogAttribute(processedLogAttributes, 'sentry.sdk.version', version); + const replay = client.getIntegrationByName string }>('Replay'); + setLogAttribute(processedLogAttributes, 'sentry.replay_id', replay?.getReplayId()); + const beforeLogMessage = beforeLog.message; if (isParameterizedString(beforeLogMessage)) { const { __sentry_template_string__, __sentry_template_values__ = [] } = beforeLogMessage; From 38cc574d9f6231c3a922b1ccf42564cca9409aed Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 9 Sep 2025 08:36:13 +0100 Subject: [PATCH 16/19] fix(react): Remove `handleExistingNavigation` (#17534) Removes recently introduced special-casing lazy-route -> lazy-route transaction name updates. This completes the fix in #17438. E2E tests are updated to replicate a similar case in the reproduction here: https://github.com/getsentry/sentry-javascript/issues/17417 Also manually tested on the reproduction. --------- Co-authored-by: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> --- .../src/pages/InnerLazyRoutes.tsx | 3 + .../tests/transactions.test.ts | 121 ++++++- .../src/reactrouter-compat-utils/index.ts | 3 - .../instrumentation.tsx | 128 +------- .../src/reactrouter-compat-utils/utils.ts | 31 -- .../instrumentation.test.tsx | 40 --- .../test/reactrouter-cross-usage.test.tsx | 302 ++++-------------- 7 files changed, 195 insertions(+), 433 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/InnerLazyRoutes.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/InnerLazyRoutes.tsx index 42324bb09391..dee76ac790aa 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/InnerLazyRoutes.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/InnerLazyRoutes.tsx @@ -32,6 +32,9 @@ export const someMoreNestedRoutes = [ Navigate to Another Lazy Route + + Navigate to Upper Lazy Route + ), }, diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts index 8263af9e6a28..2cfa2935b5bf 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts @@ -107,7 +107,15 @@ test('Creates navigation transactions between two different lazy routes', async expect(secondEvent.contexts?.trace?.op).toBe('navigation'); }); -test('Creates navigation transactions from inner lazy route to another lazy route', async ({ page }) => { +test('Creates navigation transactions from inner lazy route to another lazy route with history navigation', async ({ + page, +}) => { + await page.goto('/'); + + // Navigate to inner lazy route first + const navigationToInner = page.locator('id=navigation'); + await expect(navigationToInner).toBeVisible(); + // First, navigate to the inner lazy route const firstTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { return ( @@ -117,11 +125,6 @@ test('Creates navigation transactions from inner lazy route to another lazy rout ); }); - await page.goto('/'); - - // Navigate to inner lazy route first - const navigationToInner = page.locator('id=navigation'); - await expect(navigationToInner).toBeVisible(); await navigationToInner.click(); const firstEvent = await firstTransactionPromise; @@ -135,6 +138,10 @@ test('Creates navigation transactions from inner lazy route to another lazy rout expect(firstEvent.type).toBe('transaction'); expect(firstEvent.contexts?.trace?.op).toBe('navigation'); + // Click the navigation link from within the inner lazy route to another lazy route + const navigationToAnotherFromInner = page.locator('id=navigate-to-another-from-inner'); + await expect(navigationToAnotherFromInner).toBeVisible(); + // Now navigate from the inner lazy route to another lazy route const secondTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { return ( @@ -144,9 +151,6 @@ test('Creates navigation transactions from inner lazy route to another lazy rout ); }); - // Click the navigation link from within the inner lazy route to another lazy route - const navigationToAnotherFromInner = page.locator('id=navigate-to-another-from-inner'); - await expect(navigationToAnotherFromInner).toBeVisible(); await navigationToAnotherFromInner.click(); const secondEvent = await secondTransactionPromise; @@ -159,4 +163,103 @@ test('Creates navigation transactions from inner lazy route to another lazy rout expect(secondEvent.transaction).toBe('/another-lazy/sub/:id/:subId'); expect(secondEvent.type).toBe('transaction'); expect(secondEvent.contexts?.trace?.op).toBe('navigation'); + + // Go back to the previous page to ensure history navigation works as expected + const goBackTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + ); + }); + + await page.goBack(); + + const goBackEvent = await goBackTransactionPromise; + + // Validate the second go back transaction event + expect(goBackEvent.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(goBackEvent.type).toBe('transaction'); + expect(goBackEvent.contexts?.trace?.op).toBe('navigation'); + + // Navigate to the upper route + const goUpperRouteTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/lazy/inner/:id/:anotherId' + ); + }); + + const navigationToUpper = page.locator('id=navigate-to-upper'); + + await navigationToUpper.click(); + + const goUpperRouteEvent = await goUpperRouteTransactionPromise; + + // Validate the go upper route transaction event + expect(goUpperRouteEvent.transaction).toBe('/lazy/inner/:id/:anotherId'); + expect(goUpperRouteEvent.type).toBe('transaction'); + expect(goUpperRouteEvent.contexts?.trace?.op).toBe('navigation'); +}); + +test('Does not send any duplicate navigation transaction names browsing between different routes', async ({ page }) => { + const transactionNamesList: string[] = []; + + // Monitor and add all transaction names sent to Sentry for the navigations + const allTransactionsPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + if (transactionEvent?.transaction) { + transactionNamesList.push(transactionEvent.transaction); + } + + if (transactionNamesList.length >= 5) { + // Stop monitoring once we have enough transaction names + return true; + } + + return false; + }); + + // Go to root page + await page.goto('/'); + page.waitForTimeout(1000); + + // Navigate to inner lazy route + const navigationToInner = page.locator('id=navigation'); + await expect(navigationToInner).toBeVisible(); + await navigationToInner.click(); + + // Navigate to another lazy route + const navigationToAnother = page.locator('id=navigate-to-another-from-inner'); + await expect(navigationToAnother).toBeVisible(); + await page.waitForTimeout(1000); + + // Click to navigate to another lazy route + await navigationToAnother.click(); + const anotherLazyRouteContent = page.locator('id=another-lazy-route-deep'); + await expect(anotherLazyRouteContent).toBeVisible(); + await page.waitForTimeout(1000); + + // Navigate back to inner lazy route + await page.goBack(); + await expect(page.locator('id=innermost-lazy-route')).toBeVisible(); + await page.waitForTimeout(1000); + + // Navigate to upper inner lazy route + const navigationToUpper = page.locator('id=navigate-to-upper'); + await expect(navigationToUpper).toBeVisible(); + await navigationToUpper.click(); + + await page.waitForTimeout(1000); + + await allTransactionsPromise; + + expect(transactionNamesList.length).toBe(5); + expect(transactionNamesList).toEqual([ + '/', + '/lazy/inner/:id/:anotherId/:someAnotherId', + '/another-lazy/sub/:id/:subId', + '/lazy/inner/:id/:anotherId/:someAnotherId', + '/lazy/inner/:id/:anotherId', + ]); }); diff --git a/packages/react/src/reactrouter-compat-utils/index.ts b/packages/react/src/reactrouter-compat-utils/index.ts index 94f879017c13..c2b56ec446fb 100644 --- a/packages/react/src/reactrouter-compat-utils/index.ts +++ b/packages/react/src/reactrouter-compat-utils/index.ts @@ -9,8 +9,6 @@ export { createV6CompatibleWrapCreateMemoryRouter, createV6CompatibleWrapUseRoutes, handleNavigation, - handleExistingNavigationSpan, - createNewNavigationSpan, addResolvedRoutesToParent, processResolvedRoutes, updateNavigationSpan, @@ -21,7 +19,6 @@ export { resolveRouteNameAndSource, getNormalizedName, initializeRouterUtils, - isLikelyLazyRouteContext, locationIsInsideDescendantRoute, prefixWithSlash, rebuildRoutePathFromAllRoutes, diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index 5012ed1ccc9c..a464875a8575 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -44,7 +44,6 @@ import { checkRouteForAsyncHandler } from './lazy-routes'; import { getNormalizedName, initializeRouterUtils, - isLikelyLazyRouteContext, locationIsInsideDescendantRoute, prefixWithSlash, rebuildRoutePathFromAllRoutes, @@ -176,12 +175,7 @@ export function updateNavigationSpan( // Check if this span has already been named to avoid multiple updates // But allow updates if this is a forced update (e.g., when lazy routes are loaded) const hasBeenNamed = - !forceUpdate && - ( - activeRootSpan as { - __sentry_navigation_name_set__?: boolean; - } - )?.__sentry_navigation_name_set__; + !forceUpdate && (activeRootSpan as { __sentry_navigation_name_set__?: boolean })?.__sentry_navigation_name_set__; if (!hasBeenNamed) { // Get fresh branches for the current location with all loaded routes @@ -355,13 +349,7 @@ export function createV6CompatibleWrapCreateMemoryRouter< : router.state.location; if (router.state.historyAction === 'POP' && activeRootSpan) { - updatePageloadTransaction({ - activeRootSpan, - location, - routes, - basename, - allRoutes: Array.from(allRoutes), - }); + updatePageloadTransaction({ activeRootSpan, location, routes, basename, allRoutes: Array.from(allRoutes) }); } router.subscribe((state: RouterState) => { @@ -389,11 +377,7 @@ export function createReactRouterV6CompatibleTracingIntegration( options: Parameters[0] & ReactRouterOptions, version: V6CompatibleVersion, ): Integration { - const integration = browserTracingIntegration({ - ...options, - instrumentPageLoad: false, - instrumentNavigation: false, - }); + const integration = browserTracingIntegration({ ...options, instrumentPageLoad: false, instrumentNavigation: false }); const { useEffect, @@ -532,13 +516,7 @@ function wrapPatchRoutesOnNavigation( if (activeRootSpan && (spanToJSON(activeRootSpan) as { op?: string }).op === 'navigation') { updateNavigationSpan( activeRootSpan, - { - pathname: targetPath, - search: '', - hash: '', - state: null, - key: 'default', - }, + { pathname: targetPath, search: '', hash: '', state: null, key: 'default' }, Array.from(allRoutes), true, // forceUpdate = true since we're loading lazy routes _matchRoutes, @@ -559,13 +537,7 @@ function wrapPatchRoutesOnNavigation( if (pathname) { updateNavigationSpan( activeRootSpan, - { - pathname, - search: '', - hash: '', - state: null, - key: 'default', - }, + { pathname, search: '', hash: '', state: null, key: 'default' }, Array.from(allRoutes), false, // forceUpdate = false since this is after lazy routes are loaded _matchRoutes, @@ -604,18 +576,20 @@ export function handleNavigation(opts: { basename, ); - // Check if this might be a lazy route context - const isLazyRouteContext = isLikelyLazyRouteContext(allRoutes || routes, location); - const activeSpan = getActiveSpan(); const spanJson = activeSpan && spanToJSON(activeSpan); const isAlreadyInNavigationSpan = spanJson?.op === 'navigation'; // Cross usage can result in multiple navigation spans being created without this check - if (isAlreadyInNavigationSpan && activeSpan && spanJson) { - handleExistingNavigationSpan(activeSpan, spanJson, name, source, isLazyRouteContext); - } else { - createNewNavigationSpan(client, name, source, version, isLazyRouteContext); + if (!isAlreadyInNavigationSpan) { + startBrowserTracingNavigationSpan(client, { + name, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`, + }, + }); } } } @@ -726,13 +700,7 @@ export function createV6CompatibleWithSentryReactRouterRouting

, - name: string, - source: TransactionSource, - isLikelyLazyRoute: boolean, -): void { - // Check if we've already set the name for this span using a custom property - const hasBeenNamed = ( - activeSpan as { - __sentry_navigation_name_set__?: boolean; - } - )?.__sentry_navigation_name_set__; - - if (!hasBeenNamed) { - // This is the first time we're setting the name for this span - if (!spanJson.timestamp) { - activeSpan?.updateName(name); - } - - // For lazy routes, don't mark as named yet so it can be updated later - if (!isLikelyLazyRoute) { - addNonEnumerableProperty( - activeSpan as { __sentry_navigation_name_set__?: boolean }, - '__sentry_navigation_name_set__', - true, - ); - } - } - - // Always set the source attribute to keep it consistent with the current route - activeSpan?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); -} - -/** - * Creates a new navigation span - */ -export function createNewNavigationSpan( - client: Client, - name: string, - source: TransactionSource, - version: string, - isLikelyLazyRoute: boolean, -): void { - const newSpan = startBrowserTracingNavigationSpan(client, { - name, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`, - }, - }); - - // For lazy routes, don't mark as named yet so it can be updated later when the route loads - if (!isLikelyLazyRoute && newSpan) { - addNonEnumerableProperty( - newSpan as { __sentry_navigation_name_set__?: boolean }, - '__sentry_navigation_name_set__', - true, - ); - } -} diff --git a/packages/react/src/reactrouter-compat-utils/utils.ts b/packages/react/src/reactrouter-compat-utils/utils.ts index 8f7abb7d548e..c0750c17c57c 100644 --- a/packages/react/src/reactrouter-compat-utils/utils.ts +++ b/packages/react/src/reactrouter-compat-utils/utils.ts @@ -14,37 +14,6 @@ export function initializeRouterUtils(matchRoutes: MatchRoutes, stripBasename: b _stripBasename = stripBasename; } -/** - * Checks if the given routes or location context suggests this might be a lazy route scenario. - * This helps determine if we should delay marking navigation spans as "named" to allow for updates - * when lazy routes are loaded. - */ -export function isLikelyLazyRouteContext(routes: RouteObject[], location: Location): boolean { - // Check if any route in the current match has lazy properties - const hasLazyRoute = routes.some(route => { - return ( - // React Router lazy() route - route.lazy || - // Route with async handlers that might load child routes - (route.handle && - typeof route.handle === 'object' && - Object.values(route.handle).some(handler => typeof handler === 'function')) - ); - }); - - if (hasLazyRoute) { - return true; - } - - // Check if current route is unmatched, which might indicate a lazy route that hasn't loaded yet - const currentMatches = _matchRoutes(routes, location); - if (!currentMatches || currentMatches.length === 0) { - return true; - } - - return false; -} - // Helper functions function pickPath(match: RouteMatch): string { return trimWildcard(match.route.path || ''); diff --git a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx index 840adbf0a816..0eeeeb342287 100644 --- a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx +++ b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx @@ -1,16 +1,13 @@ /** * @vitest-environment jsdom */ -import { startBrowserTracingNavigationSpan } from '@sentry/browser'; import type { Client, Span } from '@sentry/core'; import { addNonEnumerableProperty } from '@sentry/core'; import * as React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { addResolvedRoutesToParent, - createNewNavigationSpan, createReactRouterV6CompatibleTracingIntegration, - handleExistingNavigationSpan, updateNavigationSpan, } from '../../src/reactrouter-compat-utils'; import type { Location, RouteObject } from '../../src/types'; @@ -94,43 +91,6 @@ describe('reactrouter-compat-utils/instrumentation', () => { }); }); - describe('handleExistingNavigationSpan', () => { - it('should update span name when not already named', () => { - const spanJson = { op: 'navigation', data: {}, span_id: 'test', start_timestamp: 0, trace_id: 'test' }; - - handleExistingNavigationSpan(mockSpan, spanJson, 'Test Route', 'route', false); - - expect(mockUpdateName).toHaveBeenCalledWith('Test Route'); - expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route'); - expect(addNonEnumerableProperty).toHaveBeenCalledWith(mockSpan, '__sentry_navigation_name_set__', true); - }); - - it('should not mark as named for lazy routes', () => { - const spanJson = { op: 'navigation', data: {}, span_id: 'test', start_timestamp: 0, trace_id: 'test' }; - - handleExistingNavigationSpan(mockSpan, spanJson, 'Test Route', 'route', true); - - expect(mockUpdateName).toHaveBeenCalledWith('Test Route'); - expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route'); - expect(addNonEnumerableProperty).not.toHaveBeenCalled(); - }); - }); - - describe('createNewNavigationSpan', () => { - it('should create new navigation span with correct attributes', () => { - createNewNavigationSpan(mockClient, 'Test Route', 'route', '6', false); - - expect(startBrowserTracingNavigationSpan).toHaveBeenCalledWith(mockClient, { - name: 'Test Route', - attributes: { - 'sentry.source': 'route', - 'sentry.op': 'navigation', - 'sentry.origin': 'auto.navigation.react.reactrouter_v6', - }, - }); - }); - }); - describe('addResolvedRoutesToParent', () => { it('should add new routes to parent with no existing children', () => { const parentRoute: RouteObject = { path: '/parent', element:

Parent
}; diff --git a/packages/react/test/reactrouter-cross-usage.test.tsx b/packages/react/test/reactrouter-cross-usage.test.tsx index 76cfe59f3df5..77d8e3d95b2e 100644 --- a/packages/react/test/reactrouter-cross-usage.test.tsx +++ b/packages/react/test/reactrouter-cross-usage.test.tsx @@ -36,10 +36,7 @@ import { const mockStartBrowserTracingPageLoadSpan = vi.fn(); const mockStartBrowserTracingNavigationSpan = vi.fn(); -const mockNavigationSpan = { - updateName: vi.fn(), - setAttribute: vi.fn(), -}; +const mockNavigationSpan = { updateName: vi.fn(), setAttribute: vi.fn() }; const mockRootSpan = { updateName: vi.fn(), @@ -126,65 +123,29 @@ describe('React Router cross usage of wrappers', () => { const ThirdLevelRoutes: React.FC = () => sentryUseRoutes([ - { - path: '/', - element:
, - }, - { - path: ':id', - element: , - }, + { path: '/', element:
}, + { path: ':id', element: }, ]); const SecondLevelRoutes: React.FC = () => sentryUseRoutes([ - { - path: 'third-level/*', - element: , - }, - { - path: '/', - element:
, - }, - { - path: '*', - element:
, - }, + { path: 'third-level/*', element: }, + { path: '/', element:
}, + { path: '*', element:
}, ]); const TopLevelRoutes: React.FC = () => sentryUseRoutes([ - { - path: 'second-level/:id/*', - element: , - }, - { - path: '/', - element:
, - }, - { - path: '*', - element:
, - }, + { path: 'second-level/:id/*', element: }, + { path: '/', element:
}, + { path: '*', element:
}, ]); const createSentryMemoryRouter = wrapCreateMemoryRouterV6(createMemoryRouter); - const router = createSentryMemoryRouter( - [ - { - children: [ - { - path: '/*', - element: , - }, - ], - }, - ], - { - initialEntries: ['/second-level/321/third-level/123'], - }, - ); + const router = createSentryMemoryRouter([{ children: [{ path: '/*', element: }] }], { + initialEntries: ['/second-level/321/third-level/123'], + }); const { container } = render( @@ -218,42 +179,21 @@ describe('React Router cross usage of wrappers', () => { const ThirdLevelRoutes: React.FC = () => sentryUseRoutes([ - { - path: '/', - element:
, - }, - { - path: ':id', - element: , - }, + { path: '/', element:
}, + { path: ':id', element: }, ]); const SecondLevelRoutes: React.FC = () => sentryUseRoutes([ - { - path: 'third-level/*', - element: , - }, - { - path: '/', - element:
, - }, - { - path: '*', - element:
, - }, + { path: 'third-level/*', element: }, + { path: '/', element:
}, + { path: '*', element:
}, ]); const TopLevelRoutes: React.FC = () => sentryUseRoutes([ - { - path: 'second-level/:id/*', - element: , - }, - { - path: '*', - element:
, - }, + { path: 'second-level/:id/*', element: }, + { path: '*', element:
}, ]); const createSentryMemoryRouter = wrapCreateMemoryRouterV6(createMemoryRouter); @@ -262,20 +202,12 @@ describe('React Router cross usage of wrappers', () => { [ { children: [ - { - path: '/*', - element: , - }, - { - path: '/navigate', - element: , - }, + { path: '/*', element: }, + { path: '/navigate', element: }, ], }, ], - { - initialEntries: ['/navigate'], - }, + { initialEntries: ['/navigate'] }, ); const { container } = render( @@ -319,46 +251,22 @@ describe('React Router cross usage of wrappers', () => { const ThirdLevelRoutes: React.FC = () => sentryUseRoutes([ - { - path: '/', - element:
, - }, - { - path: ':id', - element: , - }, + { path: '/', element:
}, + { path: ':id', element: }, ]); const SecondLevelRoutes: React.FC = () => sentryUseRoutes([ - { - path: 'third-level/*', - element: , - }, - { - path: '/', - element:
, - }, - { - path: '*', - element:
, - }, + { path: 'third-level/*', element: }, + { path: '/', element:
}, + { path: '*', element:
}, ]); const TopLevelRoutes: React.FC = () => sentryUseRoutes([ - { - path: 'second-level/:id/*', - element: , - }, - { - path: '/', - element:
, - }, - { - path: '*', - element:
, - }, + { path: 'second-level/:id/*', element: }, + { path: '/', element:
}, + { path: '*', element:
}, ]); const SentryRoutes = withSentryReactRouterV6Routing(Routes); @@ -399,42 +307,21 @@ describe('React Router cross usage of wrappers', () => { const ThirdLevelRoutes: React.FC = () => sentryUseRoutes([ - { - path: '/', - element:
, - }, - { - path: ':id', - element: , - }, + { path: '/', element:
}, + { path: ':id', element: }, ]); const SecondLevelRoutes: React.FC = () => sentryUseRoutes([ - { - path: 'third-level/*', - element: , - }, - { - path: '/', - element:
, - }, - { - path: '*', - element:
, - }, + { path: 'third-level/*', element: }, + { path: '/', element:
}, + { path: '*', element:
}, ]); const TopLevelRoutes: React.FC = () => sentryUseRoutes([ - { - path: 'second-level/:id/*', - element: , - }, - { - path: '*', - element:
, - }, + { path: 'second-level/:id/*', element: }, + { path: '*', element:
}, ]); const SentryRoutes = withSentryReactRouterV6Routing(Routes); @@ -500,21 +387,9 @@ describe('React Router cross usage of wrappers', () => { ); - const router = createSentryMemoryRouter( - [ - { - children: [ - { - path: '/*', - element: , - }, - ], - }, - ], - { - initialEntries: ['/second-level/321/third-level/123'], - }, - ); + const router = createSentryMemoryRouter([{ children: [{ path: '/*', element: }] }], { + initialEntries: ['/second-level/321/third-level/123'], + }); const { container } = render( @@ -574,20 +449,12 @@ describe('React Router cross usage of wrappers', () => { [ { children: [ - { - path: '/*', - element: , - }, - { - path: '/navigate', - element: , - }, + { path: '/*', element: }, + { path: '/navigate', element: }, ], }, ], - { - initialEntries: ['/navigate'], - }, + { initialEntries: ['/navigate'] }, ); const { container } = render( @@ -633,14 +500,8 @@ describe('React Router cross usage of wrappers', () => { const ThirdLevelRoutes: React.FC = () => sentryUseRoutes([ - { - path: '/', - element:
, - }, - { - path: ':id', - element: , - }, + { path: '/', element:
}, + { path: ':id', element: }, ]); const SecondLevelRoutes: React.FC = () => ( @@ -653,33 +514,15 @@ describe('React Router cross usage of wrappers', () => { const TopLevelRoutes: React.FC = () => sentryUseRoutes([ - { - path: 'second-level/:id/*', - element: , - }, - { - path: '*', - element:
, - }, + { path: 'second-level/:id/*', element: }, + { path: '*', element:
}, ]); const createSentryMemoryRouter = wrapCreateMemoryRouterV6(createMemoryRouter); - const router = createSentryMemoryRouter( - [ - { - children: [ - { - path: '/*', - element: , - }, - ], - }, - ], - { - initialEntries: ['/second-level/321/third-level/123'], - }, - ); + const router = createSentryMemoryRouter([{ children: [{ path: '/*', element: }] }], { + initialEntries: ['/second-level/321/third-level/123'], + }); const { container } = render( @@ -714,14 +557,8 @@ describe('React Router cross usage of wrappers', () => { const ThirdLevelRoutes: React.FC = () => sentryUseRoutes([ - { - path: '/', - element:
, - }, - { - path: ':id', - element: , - }, + { path: '/', element:
}, + { path: ':id', element: }, ]); const SecondLevelRoutes: React.FC = () => ( @@ -734,14 +571,8 @@ describe('React Router cross usage of wrappers', () => { const TopLevelRoutes: React.FC = () => sentryUseRoutes([ - { - path: 'second-level/:id/*', - element: , - }, - { - path: '*', - element:
, - }, + { path: 'second-level/:id/*', element: }, + { path: '*', element:
}, ]); const createSentryMemoryRouter = wrapCreateMemoryRouterV6(createMemoryRouter); @@ -750,20 +581,12 @@ describe('React Router cross usage of wrappers', () => { [ { children: [ - { - path: '/*', - element: , - }, - { - path: '/navigate', - element: , - }, + { path: '/*', element: }, + { path: '/navigate', element: }, ], }, ], - { - initialEntries: ['/navigate'], - }, + { initialEntries: ['/navigate'] }, ); const { container } = render( @@ -773,10 +596,15 @@ describe('React Router cross usage of wrappers', () => { ); expect(container.innerHTML).toContain('Details'); - - // It's called 1 time from the wrapped `MemoryRouter` expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); - expect(mockNavigationSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/second-level/:id/third-level/:id', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); }); }); }); From 3f45ae8a4a4eecf91a16fc56a00f413209d13a35 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Sep 2025 10:29:04 +0200 Subject: [PATCH 17/19] feat(deps): bump @opentelemetry/instrumentation-ioredis from 0.51.0 to 0.52.0 (#17557) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@opentelemetry/instrumentation-ioredis](https://github.com/open-telemetry/opentelemetry-js-contrib/tree/HEAD/packages/instrumentation-ioredis) from 0.51.0 to 0.52.0.
Changelog

Sourced from @​opentelemetry/instrumentation-ioredis's changelog.

0.52.0 (2025-09-08)

Features

Dependencies

  • The following workspace dependencies were updated
    • devDependencies
      • @​opentelemetry/contrib-test-utils bumped from ^0.49.0 to ^0.50.0
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@opentelemetry/instrumentation-ioredis&package-manager=npm_and_yarn&previous-version=0.51.0&new-version=0.52.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/node/package.json | 2 +- yarn.lock | 27 ++++++++++++++++++++++----- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/node/package.json b/packages/node/package.json index b7412cabdd75..f54f48c371c6 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -78,7 +78,7 @@ "@opentelemetry/instrumentation-graphql": "0.51.0", "@opentelemetry/instrumentation-hapi": "0.50.0", "@opentelemetry/instrumentation-http": "0.203.0", - "@opentelemetry/instrumentation-ioredis": "0.51.0", + "@opentelemetry/instrumentation-ioredis": "0.52.0", "@opentelemetry/instrumentation-kafkajs": "0.13.0", "@opentelemetry/instrumentation-knex": "0.48.0", "@opentelemetry/instrumentation-koa": "0.51.0", diff --git a/yarn.lock b/yarn.lock index a9f6f22ae5e8..db7641e5a892 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5865,6 +5865,13 @@ dependencies: "@opentelemetry/api" "^1.3.0" +"@opentelemetry/api-logs@0.204.0": + version "0.204.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.204.0.tgz#c0285aa5c79625a1c424854393902d21732fd76b" + integrity sha512-DqxY8yoAaiBPivoJD4UtgrMS8gEmzZ5lnaxzPojzLVHBGqPxgWm4zcuvcUHZiqQ6kRX2Klel2r9y8cA2HAtqpw== + dependencies: + "@opentelemetry/api" "^1.3.0" + "@opentelemetry/api-logs@0.57.2": version "0.57.2" resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz#d4001b9aa3580367b40fe889f3540014f766cc87" @@ -5982,12 +5989,12 @@ "@opentelemetry/semantic-conventions" "^1.29.0" forwarded-parse "2.1.2" -"@opentelemetry/instrumentation-ioredis@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.51.0.tgz#47360999ad2b035aa2ac604c410272da671142d3" - integrity sha512-9IUws0XWCb80NovS+17eONXsw1ZJbHwYYMXiwsfR9TSurkLV5UNbRSKb9URHO+K+pIJILy9wCxvyiOneMr91Ig== +"@opentelemetry/instrumentation-ioredis@0.52.0": + version "0.52.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.52.0.tgz#ca5d7b1a49798ed2d29a0f212a7ca5ef95c173c5" + integrity sha512-rUvlyZwI90HRQPYicxpDGhT8setMrlHKokCtBtZgYxQWRF5RBbG4q0pGtbZvd7kyseuHbFpA3I/5z7M8b/5ywg== dependencies: - "@opentelemetry/instrumentation" "^0.203.0" + "@opentelemetry/instrumentation" "^0.204.0" "@opentelemetry/redis-common" "^0.38.0" "@opentelemetry/semantic-conventions" "^1.27.0" @@ -6113,6 +6120,15 @@ import-in-the-middle "^1.8.1" require-in-the-middle "^7.1.1" +"@opentelemetry/instrumentation@^0.204.0": + version "0.204.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.204.0.tgz#587c104c02c9ccb38932ce508d9c81514ec7a7c4" + integrity sha512-vV5+WSxktzoMP8JoYWKeopChy6G3HKk4UQ2hESCRDUUTZqQ3+nM3u8noVG0LmNfRWwcFBnbZ71GKC7vaYYdJ1g== + dependencies: + "@opentelemetry/api-logs" "0.204.0" + import-in-the-middle "^1.8.1" + require-in-the-middle "^7.1.1" + "@opentelemetry/instrumentation@^0.52.0 || ^0.53.0 || ^0.54.0 || ^0.55.0 || ^0.56.0 || ^0.57.0": version "0.57.2" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz#8924549d7941ba1b5c6f04d5529cf48330456d1d" @@ -28735,6 +28751,7 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" + uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2" From a820fa2891fdcf985b834a5b557edf351ec54539 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 9 Sep 2025 12:41:37 +0200 Subject: [PATCH 18/19] feat(node): Add incoming request headers as OTel span attributes (#17475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracting incoming request data to span attributes. It's a very long PR but most of it are test edits as the new http attributes are added to existing tests (otherwise, they would fail). Those are the most important changes: - add a function `httpHeadersToSpanAttributes` in core that is used for generating the attributes - adding extra logic in astro, bun, cloudflare, nextjs, remix and sveltekit (custom handler instrumentation) to include the attributes ⚠️ There is one test in Next.js that fails when turbopack is enabled (see [diff snippet](https://github.com/getsentry/sentry-javascript/pull/17475/files#diff-a2882f52b1398a7c6f987463b5d38c375edd391bff5665cb82257e2280c8ffeeR26-R30)). As turbopack for build is still beta (in version v15.5.0), this test is excluded for now. Issue: https://github.com/getsentry/sentry-javascript/issues/17568 Closes https://github.com/getsentry/sentry-javascript/issues/17452 --- .size-limit.js | 2 +- .../astro-4/tests/tracing.dynamic.test.ts | 25 ++ .../astro-5/tests/tracing.dynamic.test.ts | 25 ++ .../tests/tracing.serverIslands.test.ts | 5 + .../nestjs-11/tests/transactions.test.ts | 7 + .../nestjs-8/tests/transactions.test.ts | 7 + .../nestjs-basic/tests/transactions.test.ts | 7 + .../tests/propagation.test.ts | 26 ++ .../nestjs-fastify/tests/transactions.test.ts | 7 + .../tests/transactions.test.ts | 7 + .../tests/transactions.test.ts | 7 + .../test-applications/nextjs-15/next-env.d.ts | 2 +- .../test-applications/nextjs-15/package.json | 3 +- .../nextjs-15/playwright.config.mjs | 2 +- .../nextjs-15/tests/pageload-tracing.test.ts | 35 +++ .../node-connect/tests/transactions.test.ts | 7 + .../tests/transactions.test.ts | 7 + .../node-express/tests/transactions.test.ts | 38 +++ .../node-fastify-3/tests/propagation.test.ts | 40 ++- .../node-fastify-3/tests/transactions.test.ts | 7 + .../node-fastify-4/tests/propagation.test.ts | 40 ++- .../node-fastify-4/tests/transactions.test.ts | 7 + .../node-fastify-5/tests/propagation.test.ts | 40 ++- .../node-fastify-5/tests/transactions.test.ts | 7 + .../node-hapi/tests/transactions.test.ts | 11 + .../node-koa/tests/propagation.test.ts | 40 ++- .../node-koa/tests/transactions.test.ts | 7 + .../tests/sampling.test.ts | 7 + .../tests/transactions.test.ts | 7 + .../node-otel/tests/transactions.test.ts | 7 + .../nuxt-3/tests/tracing.server.test.ts | 30 ++ .../tests/performance.server.test.ts | 30 ++ .../tsx-express/tests/transactions.test.ts | 7 + .../suites/tracing/httpIntegration/test.ts | 16 + packages/astro/src/index.server.ts | 2 + packages/astro/src/server/middleware.ts | 6 + packages/astro/test/server/middleware.test.ts | 2 +- packages/aws-serverless/src/index.ts | 2 + packages/bun/src/index.ts | 2 + packages/bun/src/integrations/bunserver.ts | 6 + .../bun/test/integrations/bunserver.test.ts | 64 ++++ packages/cloudflare/src/request.ts | 5 + packages/cloudflare/test/request.test.ts | 1 + packages/core/src/index.ts | 1 + packages/core/src/utils/request.ts | 41 +++ packages/core/test/lib/utils/request.test.ts | 285 ++++++++++++++++++ packages/google-cloud-serverless/src/index.ts | 2 + .../wrapApiHandlerWithSentry.ts | 2 + .../common/utils/addHeadersAsAttributes.ts | 30 ++ .../wrapGenerationFunctionWithSentry.ts | 6 + .../src/common/wrapMiddlewareWithSentry.ts | 6 + .../src/common/wrapRouteHandlerWithSentry.ts | 6 + .../common/wrapServerComponentWithSentry.ts | 6 + .../src/edge/wrapApiHandlerWithSentry.ts | 7 + packages/nextjs/test/config/wrappers.test.ts | 6 + .../integrations/http/incoming-requests.ts | 4 + packages/node/src/index.ts | 2 + packages/remix/src/server/instrumentServer.ts | 6 + .../sveltekit/src/server-common/handle.ts | 11 + 59 files changed, 1020 insertions(+), 13 deletions(-) create mode 100644 packages/nextjs/src/common/utils/addHeadersAsAttributes.ts diff --git a/.size-limit.js b/.size-limit.js index 3819ee2e91f6..36bf0607e840 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -233,7 +233,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '152 KB', + limit: '154 KB', }, { name: '@sentry/node - without tracing', diff --git a/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.dynamic.test.ts b/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.dynamic.test.ts index 07e0467382da..4d3d93fdf81e 100644 --- a/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.dynamic.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.dynamic.test.ts @@ -78,6 +78,11 @@ test.describe('tracing in dynamically rendered (ssr) routes', () => { 'sentry.sample_rate': 1, 'sentry.source': 'route', url: expect.stringContaining('/test-ssr'), + 'http.request.header.accept': expect.any(String), + 'http.request.header.accept_encoding': 'gzip, deflate, br, zstd', + 'http.request.header.accept_language': 'en-US', + 'http.request.header.sec_fetch_mode': 'navigate', + 'http.request.header.user_agent': expect.any(String), }, op: 'http.server', origin: 'auto.http.astro', @@ -223,6 +228,11 @@ test.describe('nested SSR routes (client, server, server request)', () => { 'sentry.origin': 'auto.http.astro', 'sentry.source': 'route', url: expect.stringContaining('/user-page/myUsername123'), + 'http.request.header.accept': expect.any(String), + 'http.request.header.accept_encoding': 'gzip, deflate, br, zstd', + 'http.request.header.accept_language': 'en-US', + 'http.request.header.sec_fetch_mode': 'navigate', + 'http.request.header.user_agent': expect.any(String), }, }, }, @@ -256,6 +266,11 @@ test.describe('nested SSR routes (client, server, server request)', () => { 'sentry.origin': 'auto.http.astro', 'sentry.source': 'route', url: expect.stringContaining('/api/user/myUsername123.json'), + 'http.request.header.accept': expect.any(String), + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': expect.any(String), }, }, }, @@ -308,6 +323,11 @@ test.describe('nested SSR routes (client, server, server request)', () => { 'sentry.origin': 'auto.http.astro', 'sentry.source': 'route', url: expect.stringContaining('/catchAll/hell0/whatever-do'), + 'http.request.header.accept': expect.any(String), + 'http.request.header.accept_encoding': 'gzip, deflate, br, zstd', + 'http.request.header.accept_language': 'en-US', + 'http.request.header.sec_fetch_mode': 'navigate', + 'http.request.header.user_agent': expect.any(String), }, }, }, @@ -360,6 +380,11 @@ test.describe('parametrized vs static paths', () => { 'sentry.origin': 'auto.http.astro', 'sentry.source': 'route', url: expect.stringContaining('/user-page/settings'), + 'http.request.header.accept': expect.any(String), + 'http.request.header.accept_encoding': 'gzip, deflate, br, zstd', + 'http.request.header.accept_language': 'en-US', + 'http.request.header.sec_fetch_mode': 'navigate', + 'http.request.header.user_agent': expect.any(String), }, }, }, diff --git a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts index 9151c13907af..0c69d1f59698 100644 --- a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts @@ -79,6 +79,11 @@ test.describe('tracing in dynamically rendered (ssr) routes', () => { 'sentry.sample_rate': 1, 'sentry.source': 'route', url: expect.stringContaining('/test-ssr'), + 'http.request.header.accept': expect.any(String), + 'http.request.header.accept_encoding': 'gzip, deflate, br, zstd', + 'http.request.header.accept_language': 'en-US', + 'http.request.header.sec_fetch_mode': 'navigate', + 'http.request.header.user_agent': expect.any(String), }, op: 'http.server', origin: 'auto.http.astro', @@ -226,6 +231,11 @@ test.describe('nested SSR routes (client, server, server request)', () => { 'sentry.origin': 'auto.http.astro', 'sentry.source': 'route', url: expect.stringContaining('/user-page/myUsername123'), + 'http.request.header.accept': expect.any(String), + 'http.request.header.accept_encoding': 'gzip, deflate, br, zstd', + 'http.request.header.accept_language': 'en-US', + 'http.request.header.sec_fetch_mode': 'navigate', + 'http.request.header.user_agent': expect.any(String), }, }, }, @@ -259,6 +269,11 @@ test.describe('nested SSR routes (client, server, server request)', () => { 'sentry.origin': 'auto.http.astro', 'sentry.source': 'route', url: expect.stringContaining('/api/user/myUsername123.json'), + 'http.request.header.accept': expect.any(String), + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': expect.any(String), }, }, }, @@ -311,6 +326,11 @@ test.describe('nested SSR routes (client, server, server request)', () => { 'sentry.origin': 'auto.http.astro', 'sentry.source': 'route', url: expect.stringContaining('/catchAll/hell0/whatever-do'), + 'http.request.header.accept': expect.any(String), + 'http.request.header.accept_encoding': 'gzip, deflate, br, zstd', + 'http.request.header.accept_language': 'en-US', + 'http.request.header.sec_fetch_mode': 'navigate', + 'http.request.header.user_agent': expect.any(String), }, }, }, @@ -363,6 +383,11 @@ test.describe('parametrized vs static paths', () => { 'sentry.origin': 'auto.http.astro', 'sentry.source': 'route', url: expect.stringContaining('/user-page/settings'), + 'http.request.header.accept': expect.any(String), + 'http.request.header.accept_encoding': 'gzip, deflate, br, zstd', + 'http.request.header.accept_language': 'en-US', + 'http.request.header.sec_fetch_mode': 'navigate', + 'http.request.header.user_agent': expect.any(String), }, }, }, diff --git a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.serverIslands.test.ts b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.serverIslands.test.ts index c7496d4e6247..354655a4ac7e 100644 --- a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.serverIslands.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.serverIslands.test.ts @@ -70,6 +70,11 @@ test.describe('tracing in static routes with server islands', () => { 'sentry.op': 'http.server', 'sentry.origin': 'auto.http.astro', 'sentry.source': 'route', + 'http.request.header.accept': expect.any(String), + 'http.request.header.accept_encoding': 'gzip, deflate, br, zstd', + 'http.request.header.accept_language': 'en-US', + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': expect.any(String), }), op: 'http.server', origin: 'auto.http.astro', diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/tests/transactions.test.ts index 1209eae1ada9..2f314a10817d 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-11/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-11/tests/transactions.test.ts @@ -38,6 +38,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-transaction', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/nestjs-8/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-8/tests/transactions.test.ts index be0e03cdbc97..a7d5ed887049 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-8/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-8/tests/transactions.test.ts @@ -38,6 +38,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-transaction', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/transactions.test.ts index c37eb8da7cc1..143ebcd9a9f0 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/transactions.test.ts @@ -38,6 +38,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-transaction', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/propagation.test.ts index 78dfe680a453..0a23c1766b38 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/propagation.test.ts @@ -32,6 +32,10 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + const outgoingHttpSpanData = outgoingHttpSpan?.data || {}; + // Outgoing span (`http.client`) does not include headers as attributes + expect(Object.keys(outgoingHttpSpanData).some(key => key.startsWith('http.request.header.'))).toBe(false); + expect(traceId).toEqual(expect.any(String)); // data is passed through from the inbound request, to verify we have the correct headers set @@ -75,6 +79,13 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-outgoing-http/:id', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), @@ -106,6 +117,10 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-inbound-headers/:id', + 'http.request.header.baggage': expect.any(String), + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sentry_trace': expect.stringMatching(/[a-f0-9]{32}-[a-f0-9]{16}-1/), }, op: 'http.server', parent_span_id: outgoingHttpSpanId, @@ -146,6 +161,10 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + const outgoingHttpSpanData = outgoingHttpSpan?.data || {}; + // Outgoing span (`http.client`) does not include headers as attributes + expect(Object.keys(outgoingHttpSpanData).some(key => key.startsWith('http.request.header.'))).toBe(false); + expect(traceId).toEqual(expect.any(String)); // data is passed through from the inbound request, to verify we have the correct headers set @@ -189,6 +208,13 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-outgoing-fetch/:id', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/transactions.test.ts index ac4c8bdea83e..9730bfd6fa68 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/transactions.test.ts @@ -38,6 +38,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-transaction', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules-decorator/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules-decorator/tests/transactions.test.ts index 5fb9c98ffc66..77cb616450f9 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules-decorator/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules-decorator/tests/transactions.test.ts @@ -38,6 +38,13 @@ test('Sends an API route transaction from module', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/example-module/transaction', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts index b95aa1fe4406..63976a559898 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts @@ -38,6 +38,13 @@ test('Sends an API route transaction from module', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/example-module/transaction', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/next-env.d.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/next-env.d.ts index 1b3be0840f3f..4f11a03dc6cc 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/next-env.d.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json index 052dd62697a1..7f9b3e822628 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json @@ -6,6 +6,7 @@ "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs", "test:prod": "TEST_ENV=production playwright test", + "test:prod-turbo": "TEST_ENV=prod-turbopack playwright test", "test:dev": "TEST_ENV=development playwright test", "test:dev-turbo": "TEST_ENV=dev-turbopack playwright test", "test:build": "pnpm install && pnpm build", @@ -46,7 +47,7 @@ }, { "build-command": "pnpm test:build-turbo", - "assert-command": "pnpm test:prod && pnpm test:dev-turbo", + "assert-command": "pnpm test:prod-turbo && pnpm test:dev-turbo", "label": "nextjs-15 (turbo)" } ] diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs index e1be6810f4dc..2eaa19f24532 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs @@ -14,7 +14,7 @@ const getStartCommand = () => { return 'pnpm next dev -p 3030 2>&1 | tee .tmp_dev_server_logs'; } - if (testEnv === 'production') { + if (testEnv === 'production' || testEnv === 'prod-turbopack') { return 'pnpm next start -p 3030'; } 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 3e41c04e2644..3cad4a546508 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 @@ -22,3 +22,38 @@ test('App router transactions should be attached to the pageload request span', expect(pageloadTraceId).toBeTruthy(); expect(serverTransaction.contexts?.trace?.trace_id).toBe(pageloadTraceId); }); + +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'; + }); + + await fetch(`${baseURL}/pageload-tracing`, { + headers: { + 'User-Agent': 'Custom-NextJS-Agent/15.0', + 'Content-Type': 'text/html', + 'X-NextJS-Test': 'nextjs-header-value', + Accept: 'text/html, application/xhtml+xml', + 'X-Framework': 'Next.js', + 'X-Request-ID': 'nextjs-789', + }, + }); + + const serverTransaction = await serverTransactionPromise; + + expect(serverTransaction.contexts?.trace?.data).toEqual( + expect.objectContaining({ + 'http.request.header.user_agent': 'Custom-NextJS-Agent/15.0', + 'http.request.header.content_type': 'text/html', + 'http.request.header.x_nextjs_test': 'nextjs-header-value', + 'http.request.header.accept': 'text/html, application/xhtml+xml', + 'http.request.header.x_framework': 'Next.js', + 'http.request.header.x_request_id': 'nextjs-789', + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-connect/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-connect/tests/transactions.test.ts index ec02acca77d6..9b06ad052f58 100644 --- a/dev-packages/e2e-tests/test-applications/node-connect/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-connect/tests/transactions.test.ts @@ -39,6 +39,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-transaction', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-express-v5/tests/transactions.test.ts index 86fdffd3b452..048f70a1aba8 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-v5/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/tests/transactions.test.ts @@ -38,6 +38,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-transaction', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/node-express/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-express/tests/transactions.test.ts index b47feebcd728..1ffd9f2e498d 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/tests/transactions.test.ts @@ -38,6 +38,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-transaction', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), @@ -208,3 +215,34 @@ test('Sends an API route transaction for an errored route', async ({ baseURL }) measurements: {}, }); }); + +test('Extracts HTTP request headers as span attributes', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-express', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); + + await fetch(`${baseURL}/test-transaction`, { + headers: { + 'User-Agent': 'Custom-Agent/1.0 (Test)', + 'Content-Type': 'application/json', + 'X-Custom-Header': 'test-value', + Accept: 'application/json, text/plain', + 'X-Request-ID': 'req-123', + }, + }); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.data).toEqual( + expect.objectContaining({ + 'http.request.header.user_agent': 'Custom-Agent/1.0 (Test)', + 'http.request.header.content_type': 'application/json', + 'http.request.header.x_custom_header': 'test-value', + 'http.request.header.accept': 'application/json, text/plain', + 'http.request.header.x_request_id': 'req-123', + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-3/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-3/tests/propagation.test.ts index ee097817bafb..1cdfd67a4851 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-3/tests/propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-3/tests/propagation.test.ts @@ -32,6 +32,10 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + const outgoingHttpSpanData = outgoingHttpSpan?.data || {}; + // Outgoing span (`http.client`) does not include headers as attributes + expect(Object.keys(outgoingHttpSpanData).some(key => key.startsWith('http.request.header.'))).toBe(false); + expect(traceId).toEqual(expect.any(String)); // data is passed through from the inbound request, to verify we have the correct headers set @@ -75,6 +79,13 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-outgoing-http/:id', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), @@ -106,6 +117,10 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-inbound-headers/:id', + 'http.request.header.baggage': expect.any(String), + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sentry_trace': expect.stringMatching(/[a-f0-9]{32}-[a-f0-9]{16}-1/), }, op: 'http.server', parent_span_id: outgoingHttpSpanId, @@ -146,6 +161,10 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + const outgoingHttpSpanData = outgoingHttpSpan?.data || {}; + // Outgoing span (`http.client`) does not include headers as attributes + expect(Object.keys(outgoingHttpSpanData).some(key => key.startsWith('http.request.header.'))).toBe(false); + expect(traceId).toEqual(expect.any(String)); // data is passed through from the inbound request, to verify we have the correct headers set @@ -189,6 +208,13 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-outgoing-fetch/:id', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), @@ -198,7 +224,7 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { }); expect(inboundTransaction.contexts?.trace).toEqual({ - data: expect.objectContaining({ + data: { 'sentry.source': 'route', 'sentry.origin': 'auto.http.otel.http', 'sentry.op': 'http.server', @@ -219,8 +245,18 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'net.peer.port': expect.any(Number), 'http.status_code': 200, 'http.status_text': 'OK', + 'http.user_agent': 'node', 'http.route': '/test-inbound-headers/:id', - }), + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.baggage': expect.any(String), + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.sentry_trace': expect.stringMatching(/[a-f0-9]{32}-[a-f0-9]{16}-1/), + 'http.request.header.user_agent': 'node', + }, op: 'http.server', parent_span_id: outgoingHttpSpanId, span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-3/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-3/tests/transactions.test.ts index d4c10751f4a5..4bf9b00f127d 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-3/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-3/tests/transactions.test.ts @@ -38,6 +38,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-transaction', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/propagation.test.ts index 3746687b92c1..6e6b20b916e8 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/propagation.test.ts @@ -32,6 +32,10 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + const outgoingHttpSpanData = outgoingHttpSpan?.data || {}; + // Outgoing span (`http.client`) does not include headers as attributes + expect(Object.keys(outgoingHttpSpanData).some(key => key.startsWith('http.request.header.'))).toBe(false); + expect(traceId).toEqual(expect.any(String)); // data is passed through from the inbound request, to verify we have the correct headers set @@ -75,6 +79,13 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-outgoing-http/:id', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), @@ -106,6 +117,10 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-inbound-headers/:id', + 'http.request.header.baggage': expect.any(String), + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sentry_trace': expect.stringMatching(/[a-f0-9]{32}-[a-f0-9]{16}-1/), }, op: 'http.server', parent_span_id: outgoingHttpSpanId, @@ -146,6 +161,10 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + const outgoingHttpSpanData = outgoingHttpSpan?.data || {}; + // Outgoing span (`http.client`) does not include headers as attributes + expect(Object.keys(outgoingHttpSpanData).some(key => key.startsWith('http.request.header.'))).toBe(false); + expect(traceId).toEqual(expect.any(String)); // data is passed through from the inbound request, to verify we have the correct headers set @@ -189,6 +208,13 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-outgoing-fetch/:id', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), @@ -198,7 +224,7 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { }); expect(inboundTransaction.contexts?.trace).toEqual({ - data: expect.objectContaining({ + data: { 'sentry.source': 'route', 'sentry.origin': 'auto.http.otel.http', 'sentry.op': 'http.server', @@ -220,7 +246,17 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-inbound-headers/:id', - }), + 'http.user_agent': 'node', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.baggage': expect.any(String), + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.sentry_trace': expect.stringMatching(/[a-f0-9]{32}-[a-f0-9]{16}-1/), + 'http.request.header.user_agent': 'node', + }, op: 'http.server', parent_span_id: outgoingHttpSpanId, span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/transactions.test.ts index 1f049b802bca..eadf89abe7ae 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/transactions.test.ts @@ -38,6 +38,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-transaction', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/propagation.test.ts index 6de3b988c3b5..4e903edf05b5 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/propagation.test.ts @@ -32,6 +32,10 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + const outgoingHttpSpanData = outgoingHttpSpan?.data || {}; + // Outgoing span (`http.client`) does not include headers as attributes + expect(Object.keys(outgoingHttpSpanData).some(key => key.startsWith('http.request.header.'))).toBe(false); + expect(traceId).toEqual(expect.any(String)); // data is passed through from the inbound request, to verify we have the correct headers set @@ -75,6 +79,13 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-outgoing-http/:id', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': 'localhost:3030', + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), @@ -106,6 +117,10 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-inbound-headers/:id', + 'http.request.header.baggage': expect.any(String), + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sentry_trace': expect.stringMatching(/[a-f0-9]{32}-[a-f0-9]{16}-1/), }, op: 'http.server', parent_span_id: outgoingHttpSpanId, @@ -146,6 +161,10 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + const outgoingHttpSpanData = outgoingHttpSpan?.data || {}; + // Outgoing span (`http.client`) does not include headers as attributes + expect(Object.keys(outgoingHttpSpanData).some(key => key.startsWith('http.request.header.'))).toBe(false); + expect(traceId).toEqual(expect.any(String)); // data is passed through from the inbound request, to verify we have the correct headers set @@ -189,6 +208,13 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-outgoing-fetch/:id', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), @@ -198,7 +224,7 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { }); expect(inboundTransaction.contexts?.trace).toEqual({ - data: expect.objectContaining({ + data: { 'sentry.source': 'route', 'sentry.origin': 'auto.http.otel.http', 'sentry.op': 'http.server', @@ -219,8 +245,18 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'net.peer.port': expect.any(Number), 'http.status_code': 200, 'http.status_text': 'OK', + 'http.user_agent': 'node', 'http.route': '/test-inbound-headers/:id', - }), + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.baggage': expect.any(String), + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.sentry_trace': expect.stringMatching(/[a-f0-9]{32}-[a-f0-9]{16}-1/), + 'http.request.header.user_agent': 'node', + }, op: 'http.server', parent_span_id: outgoingHttpSpanId, span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/transactions.test.ts index e148c8158cd8..3a00e0616f57 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/transactions.test.ts @@ -38,6 +38,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-transaction', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/node-hapi/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-hapi/tests/transactions.test.ts index 3f332992d0e7..bd6540b088d3 100644 --- a/dev-packages/e2e-tests/test-applications/node-hapi/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-hapi/tests/transactions.test.ts @@ -37,6 +37,13 @@ test('Sends successful transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-success', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), @@ -57,6 +64,10 @@ test('Sends successful transaction', async ({ baseURL }) => { const spans = transactionEvent.spans || []; + spans.forEach(span => { + expect(Object.keys(span.data).some(key => key.startsWith('http.request.header.'))).toBe(false); + }); + expect(spans).toEqual([ { data: { diff --git a/dev-packages/e2e-tests/test-applications/node-koa/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/node-koa/tests/propagation.test.ts index f7e8639b7ace..592c5a4717f4 100644 --- a/dev-packages/e2e-tests/test-applications/node-koa/tests/propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-koa/tests/propagation.test.ts @@ -31,6 +31,10 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + const outgoingHttpSpanData = outgoingHttpSpan?.data || {}; + // Outgoing span (`http.client`) does not include headers as attributes + expect(Object.keys(outgoingHttpSpanData).some(key => key.startsWith('http.request.header.'))).toBe(false); + expect(traceId).toEqual(expect.any(String)); // data is passed through from the inbound request, to verify we have the correct headers set @@ -74,6 +78,13 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-outgoing-http/:id', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), @@ -105,6 +116,10 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-inbound-headers/:id', + 'http.request.header.baggage': expect.stringContaining(traceId!), // we already check if traceId is defined + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sentry_trace': expect.stringMatching(/[a-f0-9]{32}-[a-f0-9]{16}-1/), }, op: 'http.server', parent_span_id: outgoingHttpSpanId, @@ -145,6 +160,10 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + const outgoingHttpSpanData = outgoingHttpSpan?.data || {}; + // Outgoing span (`http.client`) does not include headers as attributes + expect(Object.keys(outgoingHttpSpanData).some(key => key.startsWith('http.request.header.'))).toBe(false); + expect(traceId).toEqual(expect.any(String)); // data is passed through from the inbound request, to verify we have the correct headers set @@ -188,6 +207,13 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-outgoing-fetch/:id', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': 'localhost:3030', + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), @@ -197,7 +223,7 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { }); expect(inboundTransaction.contexts?.trace).toEqual({ - data: expect.objectContaining({ + data: { 'sentry.source': 'route', 'sentry.origin': 'auto.http.otel.http', 'sentry.op': 'http.server', @@ -219,7 +245,17 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-inbound-headers/:id', - }), + 'http.user_agent': 'node', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.baggage': expect.stringContaining(traceId!), // we already check if traceId is defined + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.sentry_trace': expect.stringMatching(/[a-f0-9]{32}-[a-f0-9]{16}-1/), + 'http.request.header.user_agent': 'node', + }, op: 'http.server', parent_span_id: outgoingHttpSpanId, span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/node-koa/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-koa/tests/transactions.test.ts index 966dbc5937e3..53803a8882e6 100644 --- a/dev-packages/e2e-tests/test-applications/node-koa/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-koa/tests/transactions.test.ts @@ -38,6 +38,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-transaction', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/tests/sampling.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/tests/sampling.test.ts index 5ca9077634d2..84d783b1d567 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/tests/sampling.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/tests/sampling.test.ts @@ -36,6 +36,13 @@ test('Sends a sampled API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/task', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, origin: 'auto.http.otel.http', op: 'http.server', diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts index 3e12007c0d75..a586146eece5 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts @@ -50,6 +50,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-transaction', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/node-otel/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-otel/tests/transactions.test.ts index c6abde474439..1f79fd438719 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel/tests/transactions.test.ts @@ -50,6 +50,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-transaction', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.server.test.ts index f1df13a71ab3..e7b6a5ddfc09 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.server.test.ts @@ -43,3 +43,33 @@ test('does not send transactions for build asset folder "_nuxt"', async ({ page expect(transactionEvent.transaction).toBe('GET /test-param/:param()'); }); + +test('extracts HTTP request headers as span attributes', async ({ baseURL }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction.includes('GET /api/test-param/'); + }); + + await fetch(`${baseURL}/api/test-param/headers-test`, { + headers: { + 'User-Agent': 'Custom-Nuxt-Agent/3.0', + 'Content-Type': 'application/json', + 'X-Nuxt-Test': 'nuxt-header-value', + Accept: 'application/json, text/html', + 'X-Framework': 'Nuxt', + 'X-Request-ID': 'nuxt-456', + }, + }); + + const transaction = await transactionPromise; + + expect(transaction.contexts?.trace?.data).toEqual( + expect.objectContaining({ + 'http.request.header.user_agent': 'Custom-Nuxt-Agent/3.0', + 'http.request.header.content_type': 'application/json', + 'http.request.header.x_nuxt_test': 'nuxt-header-value', + 'http.request.header.accept': 'application/json, text/html', + 'http.request.header.x_framework': 'Nuxt', + 'http.request.header.x_request_id': 'nuxt-456', + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.server.test.ts index 4cc3fb5cef9e..9fd87b052374 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.server.test.ts @@ -32,3 +32,33 @@ test('server pageload request span has nested request span for sub request', asy ]), ); }); + +test('extracts HTTP request headers as span attributes', async ({ page, baseURL }) => { + const serverTxnEventPromise = waitForTransaction('sveltekit-2', txnEvent => { + return txnEvent?.transaction === 'GET /api/users'; + }); + + await fetch(`${baseURL}/api/users`, { + headers: { + 'User-Agent': 'Custom-SvelteKit-Agent/1.0', + 'Content-Type': 'application/json', + 'X-Test-Header': 'sveltekit-test-value', + Accept: 'application/json', + 'X-Framework': 'SvelteKit', + 'X-Request-ID': 'sveltekit-123', + }, + }); + + const serverTxnEvent = await serverTxnEventPromise; + + expect(serverTxnEvent.contexts?.trace?.data).toEqual( + expect.objectContaining({ + 'http.request.header.user_agent': 'Custom-SvelteKit-Agent/1.0', + 'http.request.header.content_type': 'application/json', + 'http.request.header.x_test_header': 'sveltekit-test-value', + 'http.request.header.accept': 'application/json', + 'http.request.header.x_framework': 'SvelteKit', + 'http.request.header.x_request_id': 'sveltekit-123', + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/tsx-express/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/tsx-express/tests/transactions.test.ts index 2a0ca499d02c..fca8f1b85528 100644 --- a/dev-packages/e2e-tests/test-applications/tsx-express/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/tsx-express/tests/transactions.test.ts @@ -38,6 +38,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-transaction', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts index d135ca97ccb1..48fab1be2e9d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts @@ -2,6 +2,18 @@ import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests, createRunner } from '../../../utils/runner'; import { createTestServer } from '../../../utils/server'; +function getCommonHttpRequestHeaders(): Record { + return { + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', + }; +} + describe('httpIntegration', () => { afterAll(() => { cleanupChildProcesses(); @@ -118,6 +130,7 @@ describe('httpIntegration', () => { 'sentry.sample_rate': 1, 'sentry.source': 'route', url: `http://localhost:${port}/test`, + ...getCommonHttpRequestHeaders(), }); }, }) @@ -159,6 +172,9 @@ describe('httpIntegration', () => { 'sentry.sample_rate': 1, 'sentry.source': 'route', url: `http://localhost:${port}/test`, + 'http.request.header.content_length': '9', + 'http.request.header.content_type': 'text/plain;charset=UTF-8', + ...getCommonHttpRequestHeaders(), }); }, }) diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index ce9a1b1fa65a..c91d98725d9b 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -59,6 +59,8 @@ export { getSpanStatusFromHttpCode, getTraceData, getTraceMetaTags, + httpHeadersToSpanAttributes, + winterCGHeadersToDict, graphqlIntegration, hapiIntegration, httpIntegration, diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index e80020ba0913..61f7913cf1b1 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -18,10 +18,12 @@ import { getClient, getCurrentScope, getTraceMetaTags, + httpHeadersToSpanAttributes, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setHttpStatus, startSpan, + winterCGHeadersToDict, withIsolationScope, } from '@sentry/node'; import type { APIContext, MiddlewareResponseHandler, RoutePart } from 'astro'; @@ -220,6 +222,10 @@ async function instrumentRequestStartHttpServerSpan( // This is here for backwards compatibility, we used to set this here before method, url: stripUrlQueryAndFragment(ctx.url.href), + ...httpHeadersToSpanAttributes( + winterCGHeadersToDict(request.headers), + getClient()?.getOptions().sendDefaultPii ?? false, + ), }; if (parametrizedRoute) { diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index 13e54d1537cb..03933582c846 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -54,7 +54,7 @@ describe('sentryMiddleware', () => { } as any; }); vi.spyOn(SentryNode, 'getActiveSpan').mockImplementation(getSpanMock); - vi.spyOn(SentryNode, 'getClient').mockImplementation(() => ({}) as Client); + vi.spyOn(SentryNode, 'getClient').mockImplementation(() => ({ getOptions: () => ({}) }) as Client); vi.spyOn(SentryNode, 'getTraceMetaTags').mockImplementation( () => ` diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 7d7455d496bb..a041e0a7231f 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -42,6 +42,8 @@ export { close, getSentryRelease, createGetModuleFromFilename, + httpHeadersToSpanAttributes, + winterCGHeadersToDict, // eslint-disable-next-line deprecation/deprecation anrIntegration, // eslint-disable-next-line deprecation/deprecation diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index ec092bcdbbba..e0ce86b1bd23 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -62,6 +62,8 @@ export { close, getSentryRelease, createGetModuleFromFilename, + httpHeadersToSpanAttributes, + winterCGHeadersToDict, // eslint-disable-next-line deprecation/deprecation anrIntegration, // eslint-disable-next-line deprecation/deprecation diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index c31aa0e84ebc..9c235b8bc97c 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -3,6 +3,8 @@ import { captureException, continueTrace, defineIntegration, + getClient, + httpHeadersToSpanAttributes, isURLObjectRelative, parseStringToURLObject, SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, @@ -205,6 +207,10 @@ function wrapRequestHandler( routeName = route; } + const client = getClient(); + const sendDefaultPii = client?.getOptions().sendDefaultPii ?? false; + Object.assign(attributes, httpHeadersToSpanAttributes(request.headers.toJSON(), sendDefaultPii)); + isolationScope.setSDKProcessingMetadata({ normalizedRequest: { url: request.url, diff --git a/packages/bun/test/integrations/bunserver.test.ts b/packages/bun/test/integrations/bunserver.test.ts index 77ac2440915b..9792c59c2691 100644 --- a/packages/bun/test/integrations/bunserver.test.ts +++ b/packages/bun/test/integrations/bunserver.test.ts @@ -47,6 +47,11 @@ describe('Bun Serve Integration', () => { 'url.port': port.toString(), 'url.scheme': 'http:', 'url.domain': 'localhost', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate, br, zstd', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.user_agent': expect.stringContaining('Bun'), }, op: 'http.server', name: 'GET /users', @@ -81,6 +86,12 @@ describe('Bun Serve Integration', () => { 'url.port': port.toString(), 'url.scheme': 'http:', 'url.domain': 'localhost', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate, br, zstd', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.content_length': '0', + 'http.request.header.host': expect.any(String), + 'http.request.header.user_agent': expect.stringContaining('Bun'), }, op: 'http.server', name: 'POST /', @@ -128,6 +139,59 @@ describe('Bun Serve Integration', () => { expect(startSpanSpy).toHaveBeenCalledTimes(1); }); + test('includes HTTP request headers as span attributes', async () => { + const server = Bun.serve({ + async fetch(_req) { + return new Response('Headers test!'); + }, + port, + }); + + // Make request with custom headers + await fetch(`http://localhost:${port}/api/test`, { + method: 'POST', + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Content-Type': 'application/json', + 'X-Custom-Header': 'custom-value', + Accept: 'application/json, text/plain', + Authorization: 'Bearer token123', + }, + body: JSON.stringify({ test: 'data' }), + }); + + await server.stop(); + + // Verify span was created with header attributes + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.http.bun.serve', + 'http.request.method': 'POST', + 'sentry.source': 'url', + 'url.path': '/api/test', + 'url.full': `http://localhost:${port}/api/test`, + 'url.port': port.toString(), + 'url.scheme': 'http:', + 'url.domain': 'localhost', + // HTTP headers as span attributes following OpenTelemetry semantic conventions + 'http.request.header.user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'http.request.header.content_type': 'application/json', + 'http.request.header.x_custom_header': 'custom-value', + 'http.request.header.accept': 'application/json, text/plain', + 'http.request.header.accept_encoding': 'gzip, deflate, br, zstd', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.content_length': '15', + 'http.request.header.host': expect.any(String), + }), + op: 'http.server', + name: 'POST /api/test', + }), + expect.any(Function), + ); + }); + test('skips span creation for OPTIONS and HEAD requests', async () => { const server = Bun.serve({ async fetch(_req) { diff --git a/packages/cloudflare/src/request.ts b/packages/cloudflare/src/request.ts index edc1bccef96f..45fe548696ab 100644 --- a/packages/cloudflare/src/request.ts +++ b/packages/cloudflare/src/request.ts @@ -4,10 +4,12 @@ import { continueTrace, flush, getHttpSpanDetailsFromUrlObject, + httpHeadersToSpanAttributes, parseStringToURLObject, SEMANTIC_ATTRIBUTE_SENTRY_OP, setHttpStatus, startSpan, + winterCGHeadersToDict, withIsolationScope, } from '@sentry/core'; import type { CloudflareOptions } from './client'; @@ -64,6 +66,9 @@ export function wrapRequestHandler( attributes['user_agent.original'] = userAgentHeader; } + const sendDefaultPii = options.sendDefaultPii ?? false; + Object.assign(attributes, httpHeadersToSpanAttributes(winterCGHeadersToDict(request.headers), sendDefaultPii)); + attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = 'http.server'; addCloudResourceContext(isolationScope); diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index eb2989437396..ad323e3c5b5a 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -319,6 +319,7 @@ describe('withSentry', () => { 'sentry.sample_rate': 1, 'http.response.status_code': 200, 'http.request.body.size': 10, + 'http.request.header.content_length': '10', }, op: 'http.server', origin: 'auto.http.cloudflare', diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4447eea4dae0..ef61364ab3f0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -92,6 +92,7 @@ export { httpRequestToRequestData, extractQueryParamsFromUrl, headersToDict, + httpHeadersToSpanAttributes, } from './utils/request'; export { DEFAULT_ENVIRONMENT } from './constants'; export { addBreadcrumb } from './breadcrumbs'; diff --git a/packages/core/src/utils/request.ts b/packages/core/src/utils/request.ts index 04cd1006ba28..ffd60f3e8486 100644 --- a/packages/core/src/utils/request.ts +++ b/packages/core/src/utils/request.ts @@ -128,6 +128,47 @@ function getAbsoluteUrl({ return undefined; } +// "-user" because otherwise it would match "user-agent" +const SENSITIVE_HEADER_SNIPPETS = ['auth', 'token', 'secret', 'cookie', '-user', 'password', 'key']; + +/** + * Converts incoming HTTP request headers to OpenTelemetry span attributes following semantic conventions. + * Header names are converted to the format: http.request.header. + * where is the header name in lowercase with dashes converted to underscores. + * + * @see https://opentelemetry.io/docs/specs/semconv/registry/attributes/http/#http-request-header + */ +export function httpHeadersToSpanAttributes( + headers: Record, + sendDefaultPii: boolean = false, +): Record { + const spanAttributes: Record = {}; + + try { + Object.entries(headers).forEach(([key, value]) => { + if (value !== undefined) { + const lowerCasedKey = key.toLowerCase(); + + if (!sendDefaultPii && SENSITIVE_HEADER_SNIPPETS.some(snippet => lowerCasedKey.includes(snippet))) { + return; + } + + const normalizedKey = `http.request.header.${lowerCasedKey.replace(/-/g, '_')}`; + + if (Array.isArray(value)) { + spanAttributes[normalizedKey] = value.map(v => (v !== null && v !== undefined ? String(v) : v)).join(';'); + } else if (typeof value === 'string') { + spanAttributes[normalizedKey] = value; + } + } + }); + } catch { + // Return empty object if there's an error + } + + return spanAttributes; +} + /** Extract the query params from an URL. */ export function extractQueryParamsFromUrl(url: string): string | undefined { // url is path and query string diff --git a/packages/core/test/lib/utils/request.test.ts b/packages/core/test/lib/utils/request.test.ts index fe90578d5392..b37ee860f43f 100644 --- a/packages/core/test/lib/utils/request.test.ts +++ b/packages/core/test/lib/utils/request.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { extractQueryParamsFromUrl, headersToDict, + httpHeadersToSpanAttributes, httpRequestToRequestData, winterCGHeadersToDict, winterCGRequestToRequestData, @@ -420,4 +421,288 @@ describe('request utils', () => { expect(extractQueryParamsFromUrl(url)).toEqual(expected); }); }); + + describe('httpHeadersToSpanAttributes', () => { + it('works with empty headers object', () => { + expect(httpHeadersToSpanAttributes({})).toEqual({}); + }); + + it('converts single string header values to strings', () => { + const headers = { + 'Content-Type': 'application/json', + 'user-agent': 'test-agent', + }; + + const result = httpHeadersToSpanAttributes(headers); + + expect(result).toEqual({ + 'http.request.header.content_type': 'application/json', + 'http.request.header.user_agent': 'test-agent', + }); + }); + + it('handles array header values by joining with semicolons', () => { + const headers = { + 'custom-header': ['value1', 'value2'], + accept: ['application/json', 'text/html'], + }; + + const result = httpHeadersToSpanAttributes(headers); + + expect(result).toEqual({ + 'http.request.header.custom_header': 'value1;value2', + 'http.request.header.accept': 'application/json;text/html', + }); + }); + + it('filters undefined values in arrays when joining', () => { + const headers = { + 'undefined-values': [undefined, undefined], + 'valid-header': 'valid-value', + } as any; + + const result = httpHeadersToSpanAttributes(headers); + + expect(result).toEqual({ + 'http.request.header.valid_header': 'valid-value', + 'http.request.header.undefined_values': ';', + }); + }); + + it('ignores undefined header values', () => { + const headers = { + 'valid-header': 'valid-value', + 'undefined-header': undefined, + }; + + const result = httpHeadersToSpanAttributes(headers); + + expect(result).toEqual({ + 'http.request.header.valid_header': 'valid-value', + }); + }); + + it('adds empty array headers as empty string', () => { + const headers = { + 'empty-header': [], + 'valid-header': 'valid-value', + } as any; + + const result = httpHeadersToSpanAttributes(headers); + + expect(result).toEqual({ + 'http.request.header.empty_header': '', + 'http.request.header.valid_header': 'valid-value', + }); + }); + + it('converts header names to lowercase and replaces dashes with underscores', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-CUSTOM-HEADER': 'custom-value', + 'user-Agent': 'test-agent', + ACCEPT: 'text/html', + }; + + const result = httpHeadersToSpanAttributes(headers); + + expect(result).toEqual({ + 'http.request.header.content_type': 'application/json', + 'http.request.header.x_custom_header': 'custom-value', + 'http.request.header.user_agent': 'test-agent', + 'http.request.header.accept': 'text/html', + }); + }); + + it('handles real-world headers', () => { + const headers = { + Host: 'example.com', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + 'Accept-Encoding': 'gzip, deflate', + Connection: 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + 'Cache-Control': 'no-cache', + 'X-Forwarded-For': '192.168.1.1', + }; + + const result = httpHeadersToSpanAttributes(headers); + + expect(result).toEqual({ + 'http.request.header.host': 'example.com', + 'http.request.header.user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'http.request.header.accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'http.request.header.accept_language': 'en-US,en;q=0.5', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.upgrade_insecure_requests': '1', + 'http.request.header.cache_control': 'no-cache', + 'http.request.header.x_forwarded_for': '192.168.1.1', + }); + }); + + it('handles multiple values for the same header by joining with semicolons', () => { + const headers = { + 'x-random-header': ['test=abc123', 'preferences=dark-mode', 'number=three'], + Accept: ['application/json', 'text/html'], + }; + + const result = httpHeadersToSpanAttributes(headers); + + expect(result).toEqual({ + 'http.request.header.x_random_header': 'test=abc123;preferences=dark-mode;number=three', + 'http.request.header.accept': 'application/json;text/html', + }); + }); + + it('handles headers with empty string values', () => { + const headers = { + 'empty-header': '', + 'valid-header': 'valid-value', + }; + + const result = httpHeadersToSpanAttributes(headers); + + expect(result).toEqual({ + 'http.request.header.empty_header': '', + 'http.request.header.valid_header': 'valid-value', + }); + }); + + it('returns empty object when processing invalid headers throws error', () => { + // Create a headers object that will throw an error when iterated + const headers = {}; + Object.defineProperty(headers, Symbol.iterator, { + get() { + throw new Error('Test error'); + }, + }); + + const result = httpHeadersToSpanAttributes(headers); + + expect(result).toEqual({}); + }); + + it('stringifies non-string values (except null) in arrays and joins them', () => { + const headers = { + 'mixed-types': ['string-value', 123, true, null], + } as any; + + const result = httpHeadersToSpanAttributes(headers); + + expect(result).toEqual({ + 'http.request.header.mixed_types': 'string-value;123;true;', + }); + }); + + it('ignores non-string and non-array header values', () => { + const headers = { + 'string-header': 'valid-value', + 'number-header': 123, + 'boolean-header': true, + 'null-header': null, + 'object-header': { key: 'value' }, + } as any; + + const result = httpHeadersToSpanAttributes(headers); + + expect(result).toEqual({ + 'http.request.header.string_header': 'valid-value', + }); + }); + + describe('PII filtering', () => { + it('filters out sensitive headers when sendDefaultPii is false (default)', () => { + const headers = { + 'Content-Type': 'application/json', + 'User-Agent': 'test-agent', + Authorization: 'Bearer secret-token', + Cookie: 'session=abc123', + 'X-API-Key': 'api-key-123', + 'X-Auth-Token': 'auth-token-456', + }; + + const result = httpHeadersToSpanAttributes(headers, false); + + expect(result).toEqual({ + 'http.request.header.content_type': 'application/json', + 'http.request.header.user_agent': 'test-agent', + // Sensitive headers should be filtered out + }); + }); + + it('includes sensitive headers when sendDefaultPii is true', () => { + const headers = { + 'Content-Type': 'application/json', + 'User-Agent': 'test-agent', + Authorization: 'Bearer secret-token', + Cookie: 'session=abc123', + 'X-API-Key': 'api-key-123', + }; + + const result = httpHeadersToSpanAttributes(headers, true); + + expect(result).toEqual({ + 'http.request.header.content_type': 'application/json', + 'http.request.header.user_agent': 'test-agent', + 'http.request.header.authorization': 'Bearer secret-token', + 'http.request.header.cookie': 'session=abc123', + 'http.request.header.x_api_key': 'api-key-123', + }); + }); + + it('filters sensitive headers case-insensitively', () => { + const headers = { + AUTHORIZATION: 'Bearer secret-token', + Cookie: 'session=abc123', + 'x-api-key': 'key-123', + 'Content-Type': 'application/json', + }; + + const result = httpHeadersToSpanAttributes(headers, false); + + expect(result).toEqual({ + 'http.request.header.content_type': 'application/json', + }); + }); + + it('filters comprehensive list of sensitive headers', () => { + const headers = { + 'Content-Type': 'application/json', + 'User-Agent': 'test-agent', + Accept: 'application/json', + Host: 'example.com', + + // Should be filtered + Authorization: 'Bearer token', + Cookie: 'session=123', + 'Set-Cookie': 'session=456', + 'X-API-Key': 'key', + 'X-Auth-Token': 'token', + 'X-Secret': 'secret', + 'x-secret-key': 'another-secret', + 'WWW-Authenticate': 'Basic', + 'Proxy-Authorization': 'Basic auth', + 'X-Access-Token': 'access', + 'X-CSRF-Token': 'csrf', + 'X-XSRF-Token': 'xsrf', + 'X-Session-Token': 'session', + 'X-Password': 'password', + 'X-Private-Key': 'private', + 'X-Forwarded-user': 'user', + 'X-Forwarded-authorization': 'auth', + }; + + const result = httpHeadersToSpanAttributes(headers, false); + + expect(result).toEqual({ + 'http.request.header.content_type': 'application/json', + 'http.request.header.user_agent': 'test-agent', + 'http.request.header.accept': 'application/json', + 'http.request.header.host': 'example.com', + }); + }); + }); + }); }); diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 0b76f7776772..83292ab11e46 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -42,6 +42,8 @@ export { close, getSentryRelease, createGetModuleFromFilename, + httpHeadersToSpanAttributes, + winterCGHeadersToDict, // eslint-disable-next-line deprecation/deprecation anrIntegration, // eslint-disable-next-line deprecation/deprecation diff --git a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts index ba50778d30ad..a0e4112404cd 100644 --- a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts @@ -15,6 +15,7 @@ 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'; @@ -87,6 +88,7 @@ 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/utils/addHeadersAsAttributes.ts b/packages/nextjs/src/common/utils/addHeadersAsAttributes.ts new file mode 100644 index 000000000000..4e8cdb3fe7c9 --- /dev/null +++ b/packages/nextjs/src/common/utils/addHeadersAsAttributes.ts @@ -0,0 +1,30 @@ +import type { Span, WebFetchHeaders } from '@sentry/core'; +import { getClient, httpHeadersToSpanAttributes, winterCGHeadersToDict } from '@sentry/core'; + +/** + * Extracts HTTP request headers as span attributes and optionally applies them to a span. + */ +export function addHeadersAsAttributes( + headers: WebFetchHeaders | Headers | Record | undefined, + span?: Span, +): Record { + if (!headers) { + return {}; + } + + const client = getClient(); + const sendDefaultPii = client?.getOptions().sendDefaultPii ?? false; + + const headersDict: Record = + headers instanceof Headers || (typeof headers === 'object' && 'get' in headers) + ? winterCGHeadersToDict(headers as Headers) + : headers; + + const headerAttributes = httpHeadersToSpanAttributes(headersDict, sendDefaultPii); + + if (span) { + span.setAttributes(headerAttributes); + } + + return headerAttributes; +} diff --git a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts index 2067ebccc245..304066cc2313 100644 --- a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts +++ b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts @@ -22,6 +22,7 @@ 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'; @@ -63,6 +64,11 @@ 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 3a9ca786d697..bd84fb4195b7 100644 --- a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts +++ b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts @@ -13,6 +13,7 @@ import { winterCGRequestToRequestData, withIsolationScope, } from '@sentry/core'; +import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; import type { EdgeRouteHandler } from '../edge/types'; @@ -59,6 +60,7 @@ export function wrapMiddlewareWithSentry( let spanName: string; let spanSource: TransactionSource; + let headerAttributes: Record = {}; if (req instanceof Request) { isolationScope.setSDKProcessingMetadata({ @@ -66,6 +68,8 @@ export function wrapMiddlewareWithSentry( }); spanName = `middleware ${req.method} ${new URL(req.url).pathname}`; spanSource = 'url'; + + headerAttributes = addHeadersAsAttributes(req.headers); } else { spanName = 'middleware'; spanSource = 'component'; @@ -84,6 +88,7 @@ export function wrapMiddlewareWithSentry( const rootSpan = getRootSpan(activeSpan); if (rootSpan) { setCapturedScopesOnSpan(rootSpan, currentScope, isolationScope); + rootSpan.setAttributes(headerAttributes); } } @@ -94,6 +99,7 @@ export function wrapMiddlewareWithSentry( attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: spanSource, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs.wrapMiddlewareWithSentry', + ...headerAttributes, }, }, () => { diff --git a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts index e1a2238b05a1..e10d51321a0e 100644 --- a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts +++ b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts @@ -19,6 +19,7 @@ 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'; @@ -39,6 +40,10 @@ 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); @@ -50,6 +55,7 @@ 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 9dd097cb75ae..f0f8e9df8717 100644 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts @@ -24,6 +24,7 @@ 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'; @@ -61,6 +62,11 @@ 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/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts index 466eb19eb1d1..6002981cfcf4 100644 --- a/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts @@ -13,6 +13,7 @@ import { winterCGRequestToRequestData, withIsolationScope, } from '@sentry/core'; +import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; import type { EdgeRouteHandler } from './types'; @@ -31,11 +32,15 @@ export function wrapApiHandlerWithSentry( const req: unknown = args[0]; const currentScope = getCurrentScope(); + let headerAttributes: Record = {}; + if (req instanceof Request) { isolationScope.setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(req), }); currentScope.setTransactionName(`${req.method} ${parameterizedRoute}`); + + headerAttributes = addHeadersAsAttributes(req.headers); } else { currentScope.setTransactionName(`handler (${parameterizedRoute})`); } @@ -58,6 +63,7 @@ export function wrapApiHandlerWithSentry( rootSpan.setAttributes({ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + ...headerAttributes, }); setCapturedScopesOnSpan(rootSpan, currentScope, isolationScope); } @@ -74,6 +80,7 @@ export function wrapApiHandlerWithSentry( attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs.wrapApiHandlerWithSentry', + ...headerAttributes, }, }, () => { diff --git a/packages/nextjs/test/config/wrappers.test.ts b/packages/nextjs/test/config/wrappers.test.ts index c96184df51cf..c61c92026f60 100644 --- a/packages/nextjs/test/config/wrappers.test.ts +++ b/packages/nextjs/test/config/wrappers.test.ts @@ -53,11 +53,14 @@ describe('data-fetching function wrappers should not create manual spans', () => test('wrapped function sets route backfill attribute when called within an active span', async () => { const mockSetAttribute = vi.fn(); + const mockSetAttributes = vi.fn(); const mockGetActiveSpan = vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue({ setAttribute: mockSetAttribute, + setAttributes: mockSetAttributes, } as any); const mockGetRootSpan = vi.spyOn(SentryCore, 'getRootSpan').mockReturnValue({ setAttribute: mockSetAttribute, + setAttributes: mockSetAttributes, } as any); const origFunction = vi.fn(async () => ({ props: {} })); @@ -72,11 +75,14 @@ describe('data-fetching function wrappers should not create manual spans', () => test('wrapped function does not set route backfill attribute for /_error route', async () => { const mockSetAttribute = vi.fn(); + const mockSetAttributes = vi.fn(); const mockGetActiveSpan = vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue({ setAttribute: mockSetAttribute, + setAttributes: mockSetAttributes, } as any); const mockGetRootSpan = vi.spyOn(SentryCore, 'getRootSpan').mockReturnValue({ setAttribute: mockSetAttribute, + setAttributes: mockSetAttributes, } as any); const origFunction = vi.fn(async () => ({ props: {} })); diff --git a/packages/node-core/src/integrations/http/incoming-requests.ts b/packages/node-core/src/integrations/http/incoming-requests.ts index 4fddeefef4b5..57588d0ac16e 100644 --- a/packages/node-core/src/integrations/http/incoming-requests.ts +++ b/packages/node-core/src/integrations/http/incoming-requests.ts @@ -19,6 +19,7 @@ import { getCurrentScope, getIsolationScope, getSpanStatusFromHttpCode, + httpHeadersToSpanAttributes, httpRequestToRequestData, parseStringToURLObject, SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -191,6 +192,8 @@ export function instrumentServer( const tracer = client.tracer; const scheme = fullUrl.startsWith('https') ? 'https' : 'http'; + const shouldSendDefaultPii = client?.getOptions().sendDefaultPii ?? false; + // We use the plain tracer.startSpan here so we can pass the span kind const span = tracer.startSpan(bestEffortTransactionName, { kind: SpanKind.SERVER, @@ -211,6 +214,7 @@ export function instrumentServer( 'http.flavor': httpVersion, 'net.transport': httpVersion?.toUpperCase() === 'QUIC' ? 'ip_udp' : 'ip_tcp', ...getRequestContentLengthAttribute(request), + ...httpHeadersToSpanAttributes(normalizedRequest.headers || {}, shouldSendDefaultPii), }, }); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index f5f3865feffa..f510ca733d19 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -92,6 +92,8 @@ export { getIsolationScope, getTraceData, getTraceMetaTags, + httpHeadersToSpanAttributes, + winterCGHeadersToDict, continueTrace, withScope, withIsolationScope, diff --git a/packages/remix/src/server/instrumentServer.ts b/packages/remix/src/server/instrumentServer.ts index db8322a0c828..109c3e0f3672 100644 --- a/packages/remix/src/server/instrumentServer.ts +++ b/packages/remix/src/server/instrumentServer.ts @@ -23,6 +23,7 @@ import { getRootSpan, getTraceData, hasSpansEnabled, + httpHeadersToSpanAttributes, isNodeEnv, loadModule, SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -31,6 +32,7 @@ import { setHttpStatus, spanToJSON, startSpan, + winterCGHeadersToDict, winterCGRequestToRequestData, withIsolationScope, } from '@sentry/core'; @@ -324,6 +326,10 @@ function wrapRequestHandler ServerBuild | Promise [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', method: request.method, + ...httpHeadersToSpanAttributes( + winterCGHeadersToDict(request.headers), + clientOptions.sendDefaultPii ?? false, + ), }, }, async span => { diff --git a/packages/sveltekit/src/server-common/handle.ts b/packages/sveltekit/src/server-common/handle.ts index 3f5797efd211..26872a0f6f24 100644 --- a/packages/sveltekit/src/server-common/handle.ts +++ b/packages/sveltekit/src/server-common/handle.ts @@ -3,10 +3,12 @@ import { continueTrace, debug, flushIfServerless, + getClient, getCurrentScope, getDefaultIsolationScope, getIsolationScope, getTraceMetaTags, + httpHeadersToSpanAttributes, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, @@ -14,6 +16,7 @@ import { spanToJSON, startSpan, updateSpanName, + winterCGHeadersToDict, winterCGRequestToRequestData, withIsolationScope, } from '@sentry/core'; @@ -176,6 +179,10 @@ async function instrumentHandle( [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeName ? 'route' : 'url', 'sveltekit.tracing.original_name': originalName, + ...httpHeadersToSpanAttributes( + winterCGHeadersToDict(event.request.headers), + getClient()?.getOptions().sendDefaultPii ?? false, + ), }); } @@ -201,6 +208,10 @@ async function instrumentHandle( [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeId ? 'route' : 'url', 'http.method': event.request.method, + ...httpHeadersToSpanAttributes( + winterCGHeadersToDict(event.request.headers), + getClient()?.getOptions().sendDefaultPii ?? false, + ), }, name: routeName, }, From 91b13b6c114d9064566337cbd8e38626d565d525 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 9 Sep 2025 11:38:39 +0200 Subject: [PATCH 19/19] meta(changelog): Update changelog for 10.11.0 --- CHANGELOG.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 810c2bba0d95..a76e7d2696d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,51 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.11.0 + +### Important Changes + +- **feat(aws): Add experimental AWS Lambda extension for tunnelling events ([#17525](https://github.com/getsentry/sentry-javascript/pull/17525))** + + This release adds an experimental Sentry Lambda extension to the existing Sentry Lambda layer. Sentry events are now tunneled through the extension and then forwarded to Sentry. This has the benefit of reducing the request processing time. + + To enable it, set `_experiments.enableLambdaExtension` in your Sentry config like this: + + ```javascript + Sentry.init({ + dsn: '', + _experiments: { + enableLambdaExtension: true, + }, + }); + ``` + +### Other Changes + +- feat(core): Add replay id to logs ([#17563](https://github.com/getsentry/sentry-javascript/pull/17563)) +- feat(core): Improve error handling for Anthropic AI instrumentation ([#17535](https://github.com/getsentry/sentry-javascript/pull/17535)) +- feat(deps): bump @opentelemetry/instrumentation-ioredis from 0.51.0 to 0.52.0 ([#17557](https://github.com/getsentry/sentry-javascript/pull/17557)) +- feat(node): Add incoming request headers as OTel span attributes ([#17475](https://github.com/getsentry/sentry-javascript/pull/17475)) +- fix(astro): Ensure traces are correctly propagated for static routes ([#17536](https://github.com/getsentry/sentry-javascript/pull/17536)) +- fix(react): Remove `handleExistingNavigation` ([#17534](https://github.com/getsentry/sentry-javascript/pull/17534)) +- ref(browser): Add more specific `mechanism.type` to errors captured by `httpClientIntegration` ([#17254](https://github.com/getsentry/sentry-javascript/pull/17254)) +- ref(browser): Set more descriptive `mechanism.type` in `browserApiErrorsIntergation` ([#17251](https://github.com/getsentry/sentry-javascript/pull/17251)) +- ref(core): Add `mechanism.type` to `trpcMiddleware` errors ([#17287](https://github.com/getsentry/sentry-javascript/pull/17287)) +- ref(core): Add more specific event `mechanism`s and span origins to `openAiIntegration` ([#17288](https://github.com/getsentry/sentry-javascript/pull/17288)) +- ref(nestjs): Add `mechanism` to captured errors ([#17312](https://github.com/getsentry/sentry-javascript/pull/17312)) + +
+ Internal Changes + +- chore: Use proper `test-utils` dependency in workspace ([#17538](https://github.com/getsentry/sentry-javascript/pull/17538)) +- chore(test): Remove `geist` font ([#17541](https://github.com/getsentry/sentry-javascript/pull/17541)) +- ci: Check for stable lockfile ([#17552](https://github.com/getsentry/sentry-javascript/pull/17552)) +- ci: Fix running of only changed E2E tests ([#17551](https://github.com/getsentry/sentry-javascript/pull/17551)) +- ci: Remove project automation workflow ([#17508](https://github.com/getsentry/sentry-javascript/pull/17508)) +- test(node-integration-tests): pin ai@5.0.30 to fix test fails ([#17542](https://github.com/getsentry/sentry-javascript/pull/17542)) + +
+ ## 10.10.0 ### Important Changes