From 724ec92fb77a19fb073156c8c2c129a4b3ef3be4 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 14 May 2025 14:10:57 +0200 Subject: [PATCH 01/13] fix(browser): Ensure pageload & navigation spans have correct data (#16279) This PR fixes two things about our idle spans emitted from `browserTracingIntegration`: 1. The navigation name was sometimes incorrect. This is because we look at `window.location.pathname` at the time when the `popstate` event is emitted - but at this point, this may not be updated yet. So a `navigation` transaction would possibly have the pathname of the previous page as transaction name. 2. The request data is also possibly incorrect - this is set by `HttpContext` integration at event processing time. However, at this time the `window.location` data is also usually already of the following navigation, so the pageload would often have wrong request data associated to it. Now, we store this on the current scope at span creation time to ensure it is actually correct. --- .../Breadcrumbs/history/navigation/test.ts | 4 +- .../suites/replay/multiple-pages/test.ts | 2 +- .../navigation-aborting-pageload/init.js | 2 +- .../navigation-aborting-pageload/test.ts | 15 ++++ .../navigation/test.ts | 85 ++++++++++++++++++- .../browser-utils/src/instrument/history.ts | 20 ++++- packages/browser/src/helpers.ts | 22 +++++ .../browser/src/integrations/httpcontext.ts | 20 ++--- .../src/tracing/browserTracingIntegration.ts | 51 ++++++++--- packages/core/src/tracing/sentrySpan.ts | 3 + packages/core/src/types-hoist/instrument.ts | 2 + 11 files changed, 193 insertions(+), 33 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/Breadcrumbs/history/navigation/test.ts b/dev-packages/browser-integration-tests/suites/integrations/Breadcrumbs/history/navigation/test.ts index c03dedd417bd..1eb7f55b60cd 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/Breadcrumbs/history/navigation/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/Breadcrumbs/history/navigation/test.ts @@ -29,14 +29,14 @@ sentryTest('should record history changes as navigation breadcrumbs', async ({ g category: 'navigation', data: { from: '/bar?a=1#fragment', - to: '[object Object]', + to: '/[object%20Object]', }, timestamp: expect.any(Number), }, { category: 'navigation', data: { - from: '[object Object]', + from: '/[object%20Object]', to: '/bar?a=1#fragment', }, timestamp: expect.any(Number), diff --git a/dev-packages/browser-integration-tests/suites/replay/multiple-pages/test.ts b/dev-packages/browser-integration-tests/suites/replay/multiple-pages/test.ts index ac046c74d337..2c059bb226f4 100644 --- a/dev-packages/browser-integration-tests/suites/replay/multiple-pages/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/multiple-pages/test.ts @@ -210,7 +210,7 @@ sentryTest( expect(replayEvent6).toEqual( getExpectedReplayEvent({ segment_id: 6, - urls: ['/spa'], + urls: [`${TEST_HOST}/spa`], request: { url: `${TEST_HOST}/spa`, headers: { diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/init.js index c0424a9b743f..8fb188a75278 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/init.js @@ -9,4 +9,4 @@ Sentry.init({ }); // Immediately navigate to a new page to abort the pageload -window.location.href = '#foo'; +window.history.pushState({}, '', '/sub-page'); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/test.ts index ad224aa6d1d9..b68d1903a0db 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/test.ts @@ -40,6 +40,9 @@ sentryTest( expect(navigationTraceId).toBeDefined(); expect(pageloadTraceId).not.toEqual(navigationTraceId); + expect(pageloadRequest.transaction).toEqual('/index.html'); + expect(navigationRequest.transaction).toEqual('/sub-page'); + expect(pageloadRequest.contexts?.trace?.data).toMatchObject({ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.browser', [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, @@ -54,5 +57,17 @@ sentryTest( [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', ['sentry.idle_span_finish_reason']: 'idleTimeout', }); + expect(pageloadRequest.request).toEqual({ + headers: { + 'User-Agent': expect.any(String), + }, + url: 'http://sentry-test.io/index.html', + }); + expect(navigationRequest.request).toEqual({ + headers: { + 'User-Agent': expect.any(String), + }, + url: 'http://sentry-test.io/sub-page', + }); }, ); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation/test.ts index 503aa73ba4ff..cd80a2e3fa8e 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation/test.ts @@ -7,7 +7,12 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; -import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers'; +import { + envelopeRequestParser, + getFirstSentryEnvelopeRequest, + shouldSkipTracingTest, + waitForTransactionRequest, +} from '../../../../utils/helpers'; sentryTest('should create a navigation transaction on page navigation', async ({ getLocalTestUrl, page }) => { if (shouldSkipTracingTest()) { @@ -31,6 +36,10 @@ sentryTest('should create a navigation transaction on page navigation', async ({ expect(navigationTraceId).toBeDefined(); expect(pageloadTraceId).not.toEqual(navigationTraceId); + expect(pageloadRequest.transaction).toEqual('/index.html'); + // Fragment is not in transaction name + expect(navigationRequest.transaction).toEqual('/index.html'); + expect(pageloadRequest.contexts?.trace?.data).toMatchObject({ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.browser', [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, @@ -45,6 +54,18 @@ sentryTest('should create a navigation transaction on page navigation', async ({ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', ['sentry.idle_span_finish_reason']: 'idleTimeout', }); + expect(pageloadRequest.request).toEqual({ + headers: { + 'User-Agent': expect.any(String), + }, + url: 'http://sentry-test.io/index.html', + }); + expect(navigationRequest.request).toEqual({ + headers: { + 'User-Agent': expect.any(String), + }, + url: 'http://sentry-test.io/index.html#foo', + }); const pageloadSpans = pageloadRequest.spans; const navigationSpans = navigationRequest.spans; @@ -69,3 +90,65 @@ sentryTest('should create a navigation transaction on page navigation', async ({ expect(pageloadSpanId).not.toEqual(navigationSpanId); }); + +// +sentryTest('should handle pushState with full URL', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadRequestPromise = waitForTransactionRequest(page, event => event.contexts?.trace?.op === 'pageload'); + const navigationRequestPromise = waitForTransactionRequest( + page, + event => event.contexts?.trace?.op === 'navigation' && event.transaction === '/sub-page', + ); + const navigationRequestPromise2 = waitForTransactionRequest( + page, + event => event.contexts?.trace?.op === 'navigation' && event.transaction === '/sub-page-2', + ); + + await page.goto(url); + await pageloadRequestPromise; + + await page.evaluate("window.history.pushState({}, '', `${window.location.origin}/sub-page`);"); + + const navigationRequest = envelopeRequestParser(await navigationRequestPromise); + + expect(navigationRequest.transaction).toEqual('/sub-page'); + + expect(navigationRequest.contexts?.trace?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + ['sentry.idle_span_finish_reason']: 'idleTimeout', + }); + expect(navigationRequest.request).toEqual({ + headers: { + 'User-Agent': expect.any(String), + }, + url: 'http://sentry-test.io/sub-page', + }); + + await page.evaluate("window.history.pushState({}, '', `${window.location.origin}/sub-page-2`);"); + + const navigationRequest2 = envelopeRequestParser(await navigationRequestPromise2); + + expect(navigationRequest2.transaction).toEqual('/sub-page-2'); + + expect(navigationRequest2.contexts?.trace?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + ['sentry.idle_span_finish_reason']: 'idleTimeout', + }); + expect(navigationRequest2.request).toEqual({ + headers: { + 'User-Agent': expect.any(String), + }, + url: 'http://sentry-test.io/sub-page-2', + }); +}); diff --git a/packages/browser-utils/src/instrument/history.ts b/packages/browser-utils/src/instrument/history.ts index 60ee888aae24..76bf43f7b398 100644 --- a/packages/browser-utils/src/instrument/history.ts +++ b/packages/browser-utils/src/instrument/history.ts @@ -47,9 +47,15 @@ export function instrumentHistory(): void { return function (this: History, ...args: unknown[]): void { const url = args.length > 2 ? args[2] : undefined; if (url) { - // coerce to string (this is what pushState does) const from = lastHref; - const to = String(url); + + // Ensure the URL is absolute + // this can be either a path, then it is relative to the current origin + // or a full URL of the current origin - other origins are not allowed + // See: https://developer.mozilla.org/en-US/docs/Web/API/History/pushState#url + // coerce to string (this is what pushState does) + const to = getAbsoluteUrl(String(url)); + // keep track of the current URL state, as we always receive only the updated state lastHref = to; @@ -67,3 +73,13 @@ export function instrumentHistory(): void { fill(WINDOW.history, 'pushState', historyReplacementFunction); fill(WINDOW.history, 'replaceState', historyReplacementFunction); } + +function getAbsoluteUrl(urlOrPath: string): string { + try { + const url = new URL(urlOrPath, WINDOW.location.origin); + return url.toString(); + } catch { + // fallback, just do nothing + return urlOrPath; + } +} diff --git a/packages/browser/src/helpers.ts b/packages/browser/src/helpers.ts index 76578fe356dc..8fe8d650f322 100644 --- a/packages/browser/src/helpers.ts +++ b/packages/browser/src/helpers.ts @@ -4,6 +4,7 @@ import { addExceptionTypeValue, addNonEnumerableProperty, captureException, + getLocationHref, getOriginalFunction, GLOBAL_OBJ, markFunctionWrapped, @@ -175,3 +176,24 @@ export function wrap( return sentryWrapped; } + +/** + * Get HTTP request data from the current page. + */ +export function getHttpRequestData(): { url: string; headers: Record } { + // grab as much info as exists and add it to the event + const url = getLocationHref(); + const { referrer } = WINDOW.document || {}; + const { userAgent } = WINDOW.navigator || {}; + + const headers = { + ...(referrer && { Referer: referrer }), + ...(userAgent && { 'User-Agent': userAgent }), + }; + const request = { + url, + headers, + }; + + return request; +} diff --git a/packages/browser/src/integrations/httpcontext.ts b/packages/browser/src/integrations/httpcontext.ts index 78e27713c78f..9517b2364e83 100644 --- a/packages/browser/src/integrations/httpcontext.ts +++ b/packages/browser/src/integrations/httpcontext.ts @@ -1,5 +1,5 @@ -import { defineIntegration, getLocationHref } from '@sentry/core'; -import { WINDOW } from '../helpers'; +import { defineIntegration } from '@sentry/core'; +import { getHttpRequestData, WINDOW } from '../helpers'; /** * Collects information about HTTP request headers and @@ -14,23 +14,17 @@ export const httpContextIntegration = defineIntegration(() => { return; } - // grab as much info as exists and add it to the event - const url = event.request?.url || getLocationHref(); - const { referrer } = WINDOW.document || {}; - const { userAgent } = WINDOW.navigator || {}; - + const reqData = getHttpRequestData(); const headers = { + ...reqData.headers, ...event.request?.headers, - ...(referrer && { Referer: referrer }), - ...(userAgent && { 'User-Agent': userAgent }), }; - const request = { + + event.request = { + ...reqData, ...event.request, - ...(url && { url }), headers, }; - - event.request = request; }, }; }); diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 643b561af583..0a4579f40774 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -12,6 +12,7 @@ import { getLocationHref, GLOBAL_OBJ, logger, + parseStringToURLObject, propagationContextFromHeaders, registerSpanErrorInstrumentation, SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, @@ -33,7 +34,7 @@ import { startTrackingWebVitals, } from '@sentry-internal/browser-utils'; import { DEBUG_BUILD } from '../debug-build'; -import { WINDOW } from '../helpers'; +import { getHttpRequestData, WINDOW } from '../helpers'; import { registerBackgroundTabDetection } from './backgroundtab'; import { linkTraces } from './linkedTraces'; import { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from './request'; @@ -399,7 +400,14 @@ export const browserTracingIntegration = ((_options: Partial Date: Wed, 14 May 2025 14:48:56 +0200 Subject: [PATCH 02/13] feat(replay): Extend default list for masking with `aria-label` (#16192) --- .../suites/replay/customEvents/test.ts | 2 +- .../replay/privacyBlock/test.ts-snapshots/privacy-chromium.json | 2 +- .../replay/privacyBlock/test.ts-snapshots/privacy-firefox.json | 2 +- .../replay/privacyBlock/test.ts-snapshots/privacy-webkit.json | 2 +- .../suites/replay/privacyBlock/test.ts-snapshots/privacy.json | 2 +- .../privacyDefault/test.ts-snapshots/privacy-chromium.json | 2 +- .../privacyDefault/test.ts-snapshots/privacy-firefox.json | 2 +- .../replay/privacyDefault/test.ts-snapshots/privacy-webkit.json | 2 +- .../suites/replay/privacyDefault/test.ts-snapshots/privacy.json | 2 +- packages/replay-internal/src/integration.ts | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts b/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts index 1d5eda8697f1..336e09a331e1 100644 --- a/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts @@ -104,7 +104,7 @@ sentryTest( nodeId: expect.any(Number), node: { attributes: { - 'aria-label': 'An Error in aria-label', + 'aria-label': '** ***** ** **********', class: 'btn btn-error', id: 'error', role: 'button', diff --git a/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy-chromium.json b/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy-chromium.json index a3c9c494b0b5..4ac06ffeb444 100644 --- a/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy-chromium.json +++ b/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy-chromium.json @@ -62,7 +62,7 @@ "type": 2, "tagName": "button", "attributes": { - "aria-label": "Click me", + "aria-label": "***** **", "onclick": "console.log('Test log')" }, "childNodes": [ diff --git a/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy-firefox.json b/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy-firefox.json index a3c9c494b0b5..4ac06ffeb444 100644 --- a/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy-firefox.json +++ b/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy-firefox.json @@ -62,7 +62,7 @@ "type": 2, "tagName": "button", "attributes": { - "aria-label": "Click me", + "aria-label": "***** **", "onclick": "console.log('Test log')" }, "childNodes": [ diff --git a/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy-webkit.json b/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy-webkit.json index a3c9c494b0b5..4ac06ffeb444 100644 --- a/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy-webkit.json +++ b/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy-webkit.json @@ -62,7 +62,7 @@ "type": 2, "tagName": "button", "attributes": { - "aria-label": "Click me", + "aria-label": "***** **", "onclick": "console.log('Test log')" }, "childNodes": [ diff --git a/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy.json b/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy.json index a3c9c494b0b5..4ac06ffeb444 100644 --- a/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy.json +++ b/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy.json @@ -62,7 +62,7 @@ "type": 2, "tagName": "button", "attributes": { - "aria-label": "Click me", + "aria-label": "***** **", "onclick": "console.log('Test log')" }, "childNodes": [ diff --git a/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy-chromium.json b/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy-chromium.json index e04944384bbd..d27e1dc96634 100644 --- a/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy-chromium.json +++ b/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy-chromium.json @@ -62,7 +62,7 @@ "type": 2, "tagName": "button", "attributes": { - "aria-label": "Click me", + "aria-label": "***** **", "onclick": "console.log('Test log')" }, "childNodes": [ diff --git a/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy-firefox.json b/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy-firefox.json index a57a8507fda9..14f3d7989f57 100644 --- a/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy-firefox.json +++ b/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy-firefox.json @@ -62,7 +62,7 @@ "type": 2, "tagName": "button", "attributes": { - "aria-label": "Click me", + "aria-label": "***** **", "onclick": "console.log('Test log')" }, "childNodes": [ diff --git a/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy-webkit.json b/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy-webkit.json index e04944384bbd..d27e1dc96634 100644 --- a/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy-webkit.json +++ b/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy-webkit.json @@ -62,7 +62,7 @@ "type": 2, "tagName": "button", "attributes": { - "aria-label": "Click me", + "aria-label": "***** **", "onclick": "console.log('Test log')" }, "childNodes": [ diff --git a/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy.json b/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy.json index 16c4caf2ed69..dd5cc92a7723 100644 --- a/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy.json +++ b/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy.json @@ -62,7 +62,7 @@ "type": 2, "tagName": "button", "attributes": { - "aria-label": "Click me", + "aria-label": "***** **", "onclick": "console.log('Test log')" }, "childNodes": [ diff --git a/packages/replay-internal/src/integration.ts b/packages/replay-internal/src/integration.ts index bc916ba591a8..6db78dced270 100644 --- a/packages/replay-internal/src/integration.ts +++ b/packages/replay-internal/src/integration.ts @@ -95,7 +95,7 @@ export class Replay implements Integration { networkResponseHeaders = [], mask = [], - maskAttributes = ['title', 'placeholder'], + maskAttributes = ['title', 'placeholder', 'aria-label'], unmask = [], block = [], unblock = [], From 8295f1d708be7f862c0ef961cd6e00cc9b8c14a5 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 14 May 2025 15:05:36 +0200 Subject: [PATCH 03/13] feat(react-router): Add server action instrumentation (#16292) --- .../react-router-7-framework/app/routes.ts | 1 + .../app/routes/performance/server-action.tsx | 24 +++++++++++++++++ .../performance/performance.server.test.ts | 27 +++++++++++++++++++ packages/react-router/src/server/sdk.ts | 2 +- 4 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/server-action.tsx diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts index c1aacf4e5ce2..b412893def52 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts @@ -16,5 +16,6 @@ export default [ route('with/:param', 'routes/performance/dynamic-param.tsx'), route('static', 'routes/performance/static.tsx'), route('server-loader', 'routes/performance/server-loader.tsx'), + route('server-action', 'routes/performance/server-action.tsx'), ]), ] satisfies RouteConfig; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/server-action.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/server-action.tsx new file mode 100644 index 000000000000..462fc6fbf54c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/server-action.tsx @@ -0,0 +1,24 @@ +import { Form } from 'react-router'; +import type { Route } from './+types/server-action'; + +export async function action({ request }: Route.ActionArgs) { + let formData = await request.formData(); + let name = formData.get('name'); + await new Promise(resolve => setTimeout(resolve, 1000)); + return { + greeting: `Hola ${name}`, + }; +} + +export default function Project({ actionData }: Route.ComponentProps) { + return ( +
+

Server action page

+
+ + +
+ {actionData ?

{actionData.greeting}

: null} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts index 36e37f1ff288..b747719b5ff2 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts @@ -132,4 +132,31 @@ test.describe('servery - performance', () => { origin: 'auto.http.react-router', }); }); + + test('should automatically instrument server action', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'POST /performance/server-action.data'; + }); + + await page.goto(`/performance/server-action`); + await page.getByRole('button', { name: 'Submit' }).click(); // this will trigger a .data request + + const transaction = await txPromise; + + expect(transaction?.spans?.[transaction.spans?.length - 1]).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.http.react-router', + 'sentry.op': 'function.react-router.action', + }, + description: 'Executing Server Action', + parent_span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + status: 'ok', + op: 'function.react-router.action', + origin: 'auto.http.react-router', + }); + }); }); diff --git a/packages/react-router/src/server/sdk.ts b/packages/react-router/src/server/sdk.ts index b0ca0e79bd49..6d77011dc636 100644 --- a/packages/react-router/src/server/sdk.ts +++ b/packages/react-router/src/server/sdk.ts @@ -45,7 +45,7 @@ export function init(options: NodeOptions): NodeClient | undefined { const overwrite = event.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE]; if ( event.type === 'transaction' && - event.transaction === 'GET *' && + (event.transaction === 'GET *' || event.transaction === 'POST *') && event.contexts?.trace?.data?.[ATTR_HTTP_ROUTE] === '*' && overwrite ) { From 6281d6bf02acd25a46650ced6405cbeaf7288089 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 14 May 2025 15:44:15 +0200 Subject: [PATCH 04/13] feat(react-router): Filter manifest requests (#16294) - Adds a regex for `GET /__manifest` requests to to the low quality tx filter - Moves the integration into the integration folder --- .../lowQualityTransactionsFilterIntegration.ts | 2 +- packages/react-router/src/server/sdk.ts | 2 +- .../server/lowQualityTransactionsFilterIntegration.test.ts | 3 ++- packages/react-router/test/server/sdk.test.ts | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) rename packages/react-router/src/server/{ => integration}/lowQualityTransactionsFilterIntegration.ts (96%) diff --git a/packages/react-router/src/server/lowQualityTransactionsFilterIntegration.ts b/packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts similarity index 96% rename from packages/react-router/src/server/lowQualityTransactionsFilterIntegration.ts rename to packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts index 705359eab62c..eec1cfa72403 100644 --- a/packages/react-router/src/server/lowQualityTransactionsFilterIntegration.ts +++ b/packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts @@ -10,7 +10,7 @@ function _lowQualityTransactionsFilterIntegration(options: NodeOptions): { name: string; processEvent: (event: Event, hint: EventHint, client: Client) => Event | null; } { - const matchedRegexes = [/GET \/node_modules\//, /GET \/favicon\.ico/, /GET \/@id\//]; + const matchedRegexes = [/GET \/node_modules\//, /GET \/favicon\.ico/, /GET \/@id\//, /GET \/__manifest\?/]; return { name: 'LowQualityTransactionsFilter', diff --git a/packages/react-router/src/server/sdk.ts b/packages/react-router/src/server/sdk.ts index 6d77011dc636..55eaf6962a28 100644 --- a/packages/react-router/src/server/sdk.ts +++ b/packages/react-router/src/server/sdk.ts @@ -5,8 +5,8 @@ import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrations as getNodeDefaultIntegrations, init as initNodeSdk } from '@sentry/node'; import { DEBUG_BUILD } from '../common/debug-build'; import { SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE } from './instrumentation/util'; +import { lowQualityTransactionsFilterIntegration } from './integration/lowQualityTransactionsFilterIntegration'; import { reactRouterServerIntegration } from './integration/reactRouterServer'; -import { lowQualityTransactionsFilterIntegration } from './lowQualityTransactionsFilterIntegration'; /** * Returns the default integrations for the React Router SDK. diff --git a/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts b/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts index 58ddf3e215d6..3aac16d0d05d 100644 --- a/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts +++ b/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts @@ -2,7 +2,7 @@ import type { Event, EventType, Integration } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import * as SentryNode from '@sentry/node'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { lowQualityTransactionsFilterIntegration } from '../../src/server/lowQualityTransactionsFilterIntegration'; +import { lowQualityTransactionsFilterIntegration } from '../../src/server/integration/lowQualityTransactionsFilterIntegration'; const loggerLog = vi.spyOn(SentryCore.logger, 'log').mockImplementation(() => {}); @@ -18,6 +18,7 @@ describe('Low Quality Transactions Filter Integration', () => { ['node_modules requests', 'GET /node_modules/some-package/index.js'], ['favicon.ico requests', 'GET /favicon.ico'], ['@id/ requests', 'GET /@id/some-id'], + ['manifest requests', 'GET /__manifest?p=%2Fperformance%2Fserver-action'], ])('%s', (description, transaction) => { const integration = lowQualityTransactionsFilterIntegration({ debug: true }) as Integration; const event = { diff --git a/packages/react-router/test/server/sdk.test.ts b/packages/react-router/test/server/sdk.test.ts index 57b51d16c042..fdb894299760 100644 --- a/packages/react-router/test/server/sdk.test.ts +++ b/packages/react-router/test/server/sdk.test.ts @@ -3,7 +3,7 @@ import type { NodeClient } from '@sentry/node'; import * as SentryNode from '@sentry/node'; import { SDK_VERSION } from '@sentry/node'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import * as LowQualityModule from '../../src/server/lowQualityTransactionsFilterIntegration'; +import * as LowQualityModule from '../../src/server/integration/lowQualityTransactionsFilterIntegration'; import { init as reactRouterInit } from '../../src/server/sdk'; const nodeInit = vi.spyOn(SentryNode, 'init'); From fcdc4d7f0cdb4ac601d0ba3c412359ab605ca7e6 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 14 May 2025 20:57:58 -0400 Subject: [PATCH 05/13] feat(browser): Track measure detail as span attributes (#16240) resolves https://github.com/getsentry/sentry-javascript/issues/16237 The SDK automatically instruments the `performance.measure` API, but doesn't support `detail`, which is the way you can attach arbitrary data to `performance.measure`. Given you can see `details` in browser dev-tools, we should probably support it in the same way in Sentry. https://developer.mozilla.org/en-US/docs/Web/API/Performance/measure detail docs: https://developer.mozilla.org/en-US/docs/Web/API/PerformanceMeasure/detail Detail is completely arbitrary, so we have to take care before parsing it. I have added tests accordingly. --- .size-limit.js | 4 +- .../src/metrics/browserMetrics.ts | 35 +++- .../test/browser/browserMetrics.test.ts | 166 +++++++++++++++++- 3 files changed, 197 insertions(+), 8 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 0c03c0ff1b8b..7721304d684a 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -52,7 +52,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '70.1 KB', + limit: '71 KB', modifyWebpackConfig: function (config) { const webpack = require('webpack'); @@ -206,7 +206,7 @@ module.exports = [ import: createImport('init'), ignore: ['next/router', 'next/constants'], gzip: true, - limit: '42 KB', + limit: '42.5 KB', }, // SvelteKit SDK (ESM) { diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 71470a0d8706..646d73ef29c3 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -1,10 +1,11 @@ /* eslint-disable max-lines */ -import type { Measurements, Span, SpanAttributes, StartSpanOptions } from '@sentry/core'; +import type { Measurements, Span, SpanAttributes, SpanAttributeValue, StartSpanOptions } from '@sentry/core'; import { browserPerformanceTimeOrigin, getActiveSpan, getComponentName, htmlTreeAsString, + isPrimitive, parseUrl, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, setMeasurement, @@ -339,7 +340,7 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries case 'mark': case 'paint': case 'measure': { - _addMeasureSpans(span, entry, startTime, duration, timeOrigin); + _addMeasureSpans(span, entry as PerformanceMeasure, startTime, duration, timeOrigin); // capture web vitals const firstHidden = getVisibilityWatcher(); @@ -421,7 +422,7 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries */ export function _addMeasureSpans( span: Span, - entry: PerformanceEntry, + entry: PerformanceMeasure, startTime: number, duration: number, timeOrigin: number, @@ -450,6 +451,34 @@ export function _addMeasureSpans( attributes['sentry.browser.measure_start_time'] = measureStartTimestamp; } + // https://developer.mozilla.org/en-US/docs/Web/API/Performance/measure#detail + if (entry.detail) { + // Handle detail as an object + if (typeof entry.detail === 'object') { + for (const [key, value] of Object.entries(entry.detail)) { + if (value && isPrimitive(value)) { + attributes[`sentry.browser.measure.detail.${key}`] = value as SpanAttributeValue; + } else { + try { + // This is user defined so we can't guarantee it's serializable + attributes[`sentry.browser.measure.detail.${key}`] = JSON.stringify(value); + } catch { + // skip + } + } + } + } else if (isPrimitive(entry.detail)) { + attributes['sentry.browser.measure.detail'] = entry.detail as SpanAttributeValue; + } else { + // This is user defined so we can't guarantee it's serializable + try { + attributes['sentry.browser.measure.detail'] = JSON.stringify(entry.detail); + } catch { + // skip + } + } + } + // Measurements from third parties can be off, which would create invalid spans, dropping transactions in the process. if (measureStartTimestamp <= measureEndTimestamp) { startAndEndSpan(span, measureStartTimestamp, measureEndTimestamp, { diff --git a/packages/browser-utils/test/browser/browserMetrics.test.ts b/packages/browser-utils/test/browser/browserMetrics.test.ts index 99cf451f824e..a6004b73622a 100644 --- a/packages/browser-utils/test/browser/browserMetrics.test.ts +++ b/packages/browser-utils/test/browser/browserMetrics.test.ts @@ -70,7 +70,8 @@ describe('_addMeasureSpans', () => { name: 'measure-1', duration: 10, startTime: 12, - } as PerformanceEntry; + detail: null, + } as PerformanceMeasure; const timeOrigin = 100; const startTime = 23; @@ -106,7 +107,8 @@ describe('_addMeasureSpans', () => { name: 'measure-1', duration: 10, startTime: 12, - } as PerformanceEntry; + detail: null, + } as PerformanceMeasure; const timeOrigin = 100; const startTime = 23; @@ -116,6 +118,165 @@ describe('_addMeasureSpans', () => { expect(spans).toHaveLength(0); }); + + it('adds measure spans with primitive detail', () => { + const spans: Span[] = []; + + getClient()?.on('spanEnd', span => { + spans.push(span); + }); + + const entry = { + entryType: 'measure', + name: 'measure-1', + duration: 10, + startTime: 12, + detail: 'test-detail', + } as PerformanceMeasure; + + const timeOrigin = 100; + const startTime = 23; + const duration = 356; + + _addMeasureSpans(span, entry, startTime, duration, timeOrigin); + + expect(spans).toHaveLength(1); + expect(spanToJSON(spans[0]!)).toEqual( + expect.objectContaining({ + description: 'measure-1', + start_timestamp: timeOrigin + startTime, + timestamp: timeOrigin + startTime + duration, + op: 'measure', + origin: 'auto.resource.browser.metrics', + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'measure', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.resource.browser.metrics', + 'sentry.browser.measure.detail': 'test-detail', + }, + }), + ); + }); + + it('adds measure spans with object detail', () => { + const spans: Span[] = []; + + getClient()?.on('spanEnd', span => { + spans.push(span); + }); + + const detail = { + component: 'Button', + action: 'click', + metadata: { id: 123 }, + }; + + const entry = { + entryType: 'measure', + name: 'measure-1', + duration: 10, + startTime: 12, + detail, + } as PerformanceMeasure; + + const timeOrigin = 100; + const startTime = 23; + const duration = 356; + + _addMeasureSpans(span, entry, startTime, duration, timeOrigin); + + expect(spans).toHaveLength(1); + expect(spanToJSON(spans[0]!)).toEqual( + expect.objectContaining({ + description: 'measure-1', + start_timestamp: timeOrigin + startTime, + timestamp: timeOrigin + startTime + duration, + op: 'measure', + origin: 'auto.resource.browser.metrics', + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'measure', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.resource.browser.metrics', + 'sentry.browser.measure.detail.component': 'Button', + 'sentry.browser.measure.detail.action': 'click', + 'sentry.browser.measure.detail.metadata': JSON.stringify({ id: 123 }), + }, + }), + ); + }); + + it('handles non-primitive detail values by stringifying them', () => { + const spans: Span[] = []; + + getClient()?.on('spanEnd', span => { + spans.push(span); + }); + + const detail = { + component: 'Button', + action: 'click', + metadata: { id: 123 }, + callback: () => {}, + }; + + const entry = { + entryType: 'measure', + name: 'measure-1', + duration: 10, + startTime: 12, + detail, + } as PerformanceMeasure; + + const timeOrigin = 100; + const startTime = 23; + const duration = 356; + + _addMeasureSpans(span, entry, startTime, duration, timeOrigin); + + expect(spans).toHaveLength(1); + const spanData = spanToJSON(spans[0]!).data; + expect(spanData['sentry.browser.measure.detail.component']).toBe('Button'); + expect(spanData['sentry.browser.measure.detail.action']).toBe('click'); + expect(spanData['sentry.browser.measure.detail.metadata']).toBe(JSON.stringify({ id: 123 })); + expect(spanData['sentry.browser.measure.detail.callback']).toBe(JSON.stringify(detail.callback)); + }); + + it('handles errors in object detail value stringification', () => { + const spans: Span[] = []; + + getClient()?.on('spanEnd', span => { + spans.push(span); + }); + + const circular: any = {}; + circular.self = circular; + + const detail = { + component: 'Button', + action: 'click', + circular, + }; + + const entry = { + entryType: 'measure', + name: 'measure-1', + duration: 10, + startTime: 12, + detail, + } as PerformanceMeasure; + + const timeOrigin = 100; + const startTime = 23; + const duration = 356; + + // Should not throw + _addMeasureSpans(span, entry, startTime, duration, timeOrigin); + + expect(spans).toHaveLength(1); + const spanData = spanToJSON(spans[0]!).data; + expect(spanData['sentry.browser.measure.detail.component']).toBe('Button'); + expect(spanData['sentry.browser.measure.detail.action']).toBe('click'); + // The circular reference should be skipped + expect(spanData['sentry.browser.measure.detail.circular']).toBeUndefined(); + }); }); describe('_addResourceSpans', () => { @@ -464,7 +625,6 @@ describe('_addNavigationSpans', () => { transferSize: 14726, encodedBodySize: 14426, decodedBodySize: 67232, - responseStatus: 200, serverTiming: [], unloadEventStart: 0, unloadEventEnd: 0, From 33e965dbc5ba34135a870db514a1b8e0e5a60c56 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 15 May 2025 12:47:33 +0200 Subject: [PATCH 06/13] fix(nextjs): Preserve `next.route` attribute on root spans (#16297) --- .../nextjs-app-dir/tests/server-components.test.ts | 1 + packages/nextjs/src/server/index.ts | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts index 4f564f2f462d..498c9b969ed9 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts @@ -25,6 +25,7 @@ test('Sends a transaction for a request to app router', async ({ page }) => { 'http.status_code': 200, 'http.target': '/server-component/parameter/1337/42', 'otel.kind': 'SERVER', + 'next.route': '/server-component/parameter/[...parameters]', }), op: 'http.server', origin: 'auto', diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index e4e437ebd691..a6594e7fae1e 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -176,6 +176,8 @@ export function init(options: NodeOptions): NodeClient | undefined { const route = spanAttributes['next.route'].replace(/\/route$/, ''); rootSpan.updateName(route); rootSpan.setAttribute(ATTR_HTTP_ROUTE, route); + // Preserving the original attribute despite internally not depending on it + rootSpan.setAttribute('next.route', route); } } @@ -322,11 +324,14 @@ export function init(options: NodeOptions): NodeClient | undefined { const method = event.contexts.trace.data[SEMATTRS_HTTP_METHOD]; // eslint-disable-next-line deprecation/deprecation const target = event.contexts?.trace?.data?.[SEMATTRS_HTTP_TARGET]; - const route = event.contexts.trace.data[ATTR_HTTP_ROUTE]; + const route = event.contexts.trace.data[ATTR_HTTP_ROUTE] || event.contexts.trace.data['next.route']; if (typeof method === 'string' && typeof route === 'string') { - event.transaction = `${method} ${route.replace(/\/route$/, '')}`; + const cleanRoute = route.replace(/\/route$/, ''); + event.transaction = `${method} ${cleanRoute}`; event.contexts.trace.data[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'route'; + // Preserve next.route in case it did not get hoisted + event.contexts.trace.data['next.route'] = cleanRoute; } // backfill transaction name for pages that would otherwise contain unparameterized routes From 5b74eeac4fea85baf6419aeba54ae0259401d720 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 15 May 2025 07:39:44 -0400 Subject: [PATCH 07/13] fix(cloudflare): Account for static fields in wrapper type (#16303) fixes https://github.com/getsentry/sentry-javascript/issues/16247 By adjusting the generic, we'll make sure that we don't erase static fields with the `instrumentDurableObjectWithSentry` function. See an example below: ```js class MyDurableObjectBase extends DurableObject { public static readonly VERSION = '1.0.0'; } const MyDurableObject = instrumentDurableObjectWithSentry( () => ({ dsn: 'https://example.com/sentry', tracesSampleRate: 1.0, }), MyDurableObjectBase, ); console.log(MyDurableObject.VERSION); // This will now work correctly ``` By moving the `DurableObjectClass` into it's own generic (`new (state: DurableObjectState, env: E) => T`), which we named `C`, it helps preserve the exact constructor type of the input class, including all its static properties and methods. This was previously being lost by not aligning the `DurableObjectClass` with the function return value. --- packages/cloudflare/src/durableobject.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index d595ccfa5985..35fbb5096a41 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -133,10 +133,11 @@ function wrapMethodWithSentry any>( * ); * ``` */ -export function instrumentDurableObjectWithSentry>( - optionsCallback: (env: E) => CloudflareOptions, - DurableObjectClass: new (state: DurableObjectState, env: E) => T, -): new (state: DurableObjectState, env: E) => T { +export function instrumentDurableObjectWithSentry< + E, + T extends DurableObject, + C extends new (state: DurableObjectState, env: E) => T, +>(optionsCallback: (env: E) => CloudflareOptions, DurableObjectClass: C): C { return new Proxy(DurableObjectClass, { construct(target, [context, env]) { setAsyncLocalStorageAsyncContextStrategy(); From f1280465b6fdd81caea849c46b95f280c83e295e Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 15 May 2025 07:40:23 -0400 Subject: [PATCH 08/13] chore: Add node 24 to profiling node README (#16301) Forgot to bump this here. --- packages/profiling-node/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/profiling-node/README.md b/packages/profiling-node/README.md index e96bc41eb569..962bc8e6834f 100644 --- a/packages/profiling-node/README.md +++ b/packages/profiling-node/README.md @@ -83,7 +83,7 @@ After the binaries are built, you should see them inside the profiling-node/lib ### Prebuilt binaries -We currently ship prebuilt binaries for a few of the most common platforms and node versions (v18-22). +We currently ship prebuilt binaries for a few of the most common platforms and node versions (v18-24). - macOS x64 - Linux ARM64 (musl) From 12c90d1020aeeed9d74cb5755d853a74d1ced204 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 15 May 2025 16:00:49 +0200 Subject: [PATCH 09/13] feat(node): Add `maxIncomingRequestBodySize` (#16225) Adds `maxIncomingRequestBodySize` to the Node `httpIntegration`. The setting controls the maximum size of HTTP request bodies attached to events. There is the option `maxRequestBodySize` ([docs](https://develop.sentry.dev/sdk/expected-features/#attaching-request-body-in-server-sdks)) in other SDKs, but to be more specific, this is named with `incoming`. Available options: - 'none': No request bodies will be attached - 'small': Request bodies up to 1,000 bytes will be attached - 'medium': Request bodies up to 10,000 bytes will be attached (default) - 'always': Request bodies will always be attached (up to 1 MB) closes https://github.com/getsentry/sentry-javascript/issues/16179 --- .../with-http/{ => base}/instrument.mjs | 0 .../express/with-http/{ => base}/scenario.mjs | 0 .../express/with-http/{ => base}/test.ts | 2 +- .../generatePayload.ts | 35 ++ .../instrument-always.mjs | 10 + .../instrument-default.mjs | 9 + .../instrument-medium.mjs | 10 + .../instrument-none.mjs | 15 + .../instrument-small.mjs | 10 + .../maxIncomingRequestBodySize/scenario.mjs | 32 ++ .../maxIncomingRequestBodySize/test.ts | 311 ++++++++++++++++++ .../http/SentryHttpInstrumentation.ts | 54 ++- packages/node/src/integrations/http/index.ts | 16 + 13 files changed, 495 insertions(+), 9 deletions(-) rename dev-packages/node-integration-tests/suites/express/with-http/{ => base}/instrument.mjs (100%) rename dev-packages/node-integration-tests/suites/express/with-http/{ => base}/scenario.mjs (100%) rename dev-packages/node-integration-tests/suites/express/with-http/{ => base}/test.ts (97%) create mode 100644 dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/generatePayload.ts create mode 100644 dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-always.mjs create mode 100644 dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-default.mjs create mode 100644 dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-medium.mjs create mode 100644 dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-none.mjs create mode 100644 dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-small.mjs create mode 100644 dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/test.ts diff --git a/dev-packages/node-integration-tests/suites/express/with-http/instrument.mjs b/dev-packages/node-integration-tests/suites/express/with-http/base/instrument.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/express/with-http/instrument.mjs rename to dev-packages/node-integration-tests/suites/express/with-http/base/instrument.mjs diff --git a/dev-packages/node-integration-tests/suites/express/with-http/scenario.mjs b/dev-packages/node-integration-tests/suites/express/with-http/base/scenario.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/express/with-http/scenario.mjs rename to dev-packages/node-integration-tests/suites/express/with-http/base/scenario.mjs diff --git a/dev-packages/node-integration-tests/suites/express/with-http/test.ts b/dev-packages/node-integration-tests/suites/express/with-http/base/test.ts similarity index 97% rename from dev-packages/node-integration-tests/suites/express/with-http/test.ts rename to dev-packages/node-integration-tests/suites/express/with-http/base/test.ts index 10dbefa74a9a..40c74a3d8888 100644 --- a/dev-packages/node-integration-tests/suites/express/with-http/test.ts +++ b/dev-packages/node-integration-tests/suites/express/with-http/base/test.ts @@ -1,5 +1,5 @@ import { afterAll, describe } from 'vitest'; -import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; describe('express with http import', () => { afterAll(() => { diff --git a/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/generatePayload.ts b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/generatePayload.ts new file mode 100644 index 000000000000..7b85c82f9ab9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/generatePayload.ts @@ -0,0 +1,35 @@ +// Payload for requests +export function generatePayload(sizeInBytes: number): { data: string } { + const baseSize = JSON.stringify({ data: '' }).length; + const contentLength = sizeInBytes - baseSize; + + return { data: 'x'.repeat(contentLength) }; +} + +// Generate the "expected" body string +export function generatePayloadString(dataLength: number, truncate?: boolean): string { + const prefix = '{"data":"'; + const suffix = truncate ? '...' : '"}'; + + const baseStructuralLength = prefix.length + suffix.length; + const dataContent = 'x'.repeat(dataLength - baseStructuralLength); + + return `${prefix}${dataContent}${suffix}`; +} + +// Functions for non-ASCII payloads (e.g. emojis) +export function generateEmojiPayload(sizeInBytes: number): { data: string } { + const baseSize = JSON.stringify({ data: '' }).length; + const contentLength = sizeInBytes - baseSize; + + return { data: '👍'.repeat(contentLength) }; +} +export function generateEmojiPayloadString(dataLength: number, truncate?: boolean): string { + const prefix = '{"data":"'; + const suffix = truncate ? '...' : '"}'; + + const baseStructuralLength = suffix.length; + const dataContent = '👍'.repeat(dataLength - baseStructuralLength); + + return `${prefix}${dataContent}${suffix}`; +} diff --git a/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-always.mjs b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-always.mjs new file mode 100644 index 000000000000..9f26662334fb --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-always.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.httpIntegration({ maxIncomingRequestBodySize: 'always' })], +}); diff --git a/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-default.mjs b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-default.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-default.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-medium.mjs b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-medium.mjs new file mode 100644 index 000000000000..92ed3d0d5d35 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-medium.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.httpIntegration({ maxIncomingRequestBodySize: 'medium' })], +}); diff --git a/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-none.mjs b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-none.mjs new file mode 100644 index 000000000000..609863666ee4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-none.mjs @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [ + Sentry.httpIntegration({ + maxIncomingRequestBodySize: 'none', + ignoreIncomingRequestBody: url => url.includes('/ignore-request-body'), + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-small.mjs b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-small.mjs new file mode 100644 index 000000000000..fc13fbe20d31 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-small.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.httpIntegration({ maxIncomingRequestBodySize: 'small' })], +}); diff --git a/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/scenario.mjs b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/scenario.mjs new file mode 100644 index 000000000000..c198c8056fea --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/scenario.mjs @@ -0,0 +1,32 @@ +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import bodyParser from 'body-parser'; +import express from 'express'; + +const app = express(); + +// Increase limit for JSON parsing +app.use(bodyParser.json({ limit: '3mb' })); +app.use(express.json({ limit: '3mb' })); + +app.post('/test-body-size', (req, res) => { + const receivedSize = JSON.stringify(req.body).length; + res.json({ + success: true, + receivedSize, + message: 'Payload processed successfully', + }); +}); + +app.post('/ignore-request-body', (req, res) => { + const receivedSize = JSON.stringify(req.body).length; + res.json({ + success: true, + receivedSize, + message: 'Payload processed successfully', + }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/test.ts b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/test.ts new file mode 100644 index 000000000000..5ae6b4e2bacc --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/test.ts @@ -0,0 +1,311 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; +import { + generateEmojiPayload, + generateEmojiPayloadString, + generatePayload, + generatePayloadString, +} from './generatePayload'; + +// Value of MAX_BODY_BYTE_LENGTH in SentryHttpIntegration +const MAX_GENERAL = 1024 * 1024; // 1MB +const MAX_MEDIUM = 10_000; +const MAX_SMALL = 1000; + +describe('express with httpIntegration and not defined maxIncomingRequestBodySize', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-default.mjs', (createRunner, test) => { + test('captures medium request bodies with default setting (medium)', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'POST /test-body-size', + request: { + data: JSON.stringify(generatePayload(MAX_MEDIUM)), + }, + }, + }) + .start(); + + await runner.makeRequest('post', '/test-body-size', { + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(generatePayload(MAX_MEDIUM)), + }); + + await runner.completed(); + }); + + test('truncates large request bodies with default setting (medium)', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'POST /test-body-size', + request: { + data: generatePayloadString(MAX_MEDIUM, true), + }, + }, + }) + .start(); + + await runner.makeRequest('post', '/test-body-size', { + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(generatePayload(MAX_MEDIUM + 1)), + }); + + await runner.completed(); + }); + }); +}); + +describe('express with httpIntegration and maxIncomingRequestBodySize: "none"', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument-none.mjs', + (createRunner, test) => { + test('does not capture any request bodies with "none" setting', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'POST /test-body-size', + request: expect.not.objectContaining({ + data: expect.any(String), + }), + }, + }) + .start(); + + await runner.makeRequest('post', '/test-body-size', { + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(generatePayload(500)), + }); + + await runner.completed(); + }); + + test('does not capture any request bodies with "none" setting and "ignoreIncomingRequestBody"', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'POST /test-body-size', + request: expect.not.objectContaining({ + data: expect.any(String), + }), + }, + }) + .expect({ + transaction: { + transaction: 'POST /ignore-request-body', + request: expect.not.objectContaining({ + data: expect.any(String), + }), + }, + }) + .start(); + + await runner.makeRequest('post', '/test-body-size', { + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(generatePayload(500)), + }); + + await runner.makeRequest('post', '/ignore-request-body', { + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(generatePayload(500)), + }); + + await runner.completed(); + }); + }, + { failsOnEsm: false }, + ); +}); + +describe('express with httpIntegration and maxIncomingRequestBodySize: "always"', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument-always.mjs', + (createRunner, test) => { + test('captures maximum allowed request body length with "always" setting', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'POST /test-body-size', + request: { + data: JSON.stringify(generatePayload(MAX_GENERAL)), + }, + }, + }) + .start(); + + await runner.makeRequest('post', '/test-body-size', { + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(generatePayload(MAX_GENERAL)), + }); + + await runner.completed(); + }); + + test('captures large request bodies with "always" setting but respects maximum size limit', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'POST /test-body-size', + request: { + data: generatePayloadString(MAX_GENERAL, true), + }, + }, + }) + .start(); + + await runner.makeRequest('post', '/test-body-size', { + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(generatePayload(MAX_GENERAL + 1)), + }); + + await runner.completed(); + }); + }, + { failsOnEsm: false }, + ); +}); + +describe('express with httpIntegration and maxIncomingRequestBodySize: "small"', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument-small.mjs', + (createRunner, test) => { + test('keeps small request bodies with "small" setting', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'POST /test-body-size', + request: { + data: JSON.stringify(generatePayload(MAX_SMALL)), + }, + }, + }) + .start(); + + await runner.makeRequest('post', '/test-body-size', { + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(generatePayload(MAX_SMALL)), + }); + + await runner.completed(); + }); + + test('truncates too large request bodies with "small" setting', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'POST /test-body-size', + request: { + data: generatePayloadString(MAX_SMALL, true), + }, + }, + }) + .start(); + + await runner.makeRequest('post', '/test-body-size', { + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(generatePayload(MAX_SMALL + 1)), + }); + + await runner.completed(); + }); + + test('truncates too large non-ASCII request bodies with "small" setting', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'POST /test-body-size', + request: { + // 250 emojis, each 4 bytes in UTF-8 (resulting in 1000 bytes --> MAX_SMALL) + data: generateEmojiPayloadString(250, true), + }, + }, + }) + .start(); + + await runner.makeRequest('post', '/test-body-size', { + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(generateEmojiPayload(MAX_SMALL + 1)), + }); + + await runner.completed(); + }); + }, + { failsOnEsm: false }, + ); +}); + +describe('express with httpIntegration and maxIncomingRequestBodySize: "medium"', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument-medium.mjs', + (createRunner, test) => { + test('keeps medium request bodies with "medium" setting', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'POST /test-body-size', + request: { + data: JSON.stringify(generatePayload(MAX_MEDIUM)), + }, + }, + }) + .start(); + + await runner.makeRequest('post', '/test-body-size', { + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(generatePayload(MAX_MEDIUM)), + }); + + await runner.completed(); + }); + + test('truncates large request bodies with "medium" setting', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'POST /test-body-size', + request: { + data: generatePayloadString(MAX_MEDIUM, true), + }, + }, + }) + .start(); + + await runner.makeRequest('post', '/test-body-size', { + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(generatePayload(MAX_MEDIUM + 1)), + }); + + await runner.completed(); + }); + }, + { failsOnEsm: false }, + ); +}); diff --git a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts index 6b8f615479e4..8eb13bc144cf 100644 --- a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts @@ -82,6 +82,22 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { */ ignoreIncomingRequestBody?: (url: string, request: http.RequestOptions) => boolean; + /** + * Controls the maximum size of incoming HTTP request bodies attached to events. + * + * Available options: + * - 'none': No request bodies will be attached + * - 'small': Request bodies up to 1,000 bytes will be attached + * - 'medium': Request bodies up to 10,000 bytes will be attached (default) + * - 'always': Request bodies will always be attached + * + * Note that even with 'always' setting, bodies exceeding 1MB will never be attached + * for performance and security reasons. + * + * @default 'medium' + */ + maxIncomingRequestBodySize?: 'none' | 'small' | 'medium' | 'always'; + /** * Whether the integration should create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for incoming requests to track the health and crash-free rate of your releases in Sentry. * Read more about Release Health: https://docs.sentry.io/product/releases/health/ @@ -299,7 +315,7 @@ export class SentryHttpInstrumentation extends InstrumentationBase) => { try { const chunk = args[0] as Buffer | string; const bufferifiedChunk = Buffer.from(chunk); - if (bodyByteLength < MAX_BODY_BYTE_LENGTH) { + if (bodyByteLength < maxBodySize) { chunks.push(bufferifiedChunk); bodyByteLength += bufferifiedChunk.byteLength; } else if (DEBUG_BUILD) { logger.log( INSTRUMENTATION_NAME, - `Dropping request body chunk because maximum body length of ${MAX_BODY_BYTE_LENGTH}b is exceeded.`, + `Dropping request body chunk because maximum body length of ${maxBodySize}b is exceeded.`, ); } } catch (err) { @@ -502,7 +531,16 @@ function patchRequestToCaptureBody(req: http.IncomingMessage, isolationScope: Sc try { const body = Buffer.concat(chunks).toString('utf-8'); if (body) { - isolationScope.setSDKProcessingMetadata({ normalizedRequest: { data: body } }); + // Using Buffer.byteLength here, because the body may contain characters that are not 1 byte long + const bodyByteLength = Buffer.byteLength(body, 'utf-8'); + const truncatedBody = + bodyByteLength > maxBodySize + ? `${Buffer.from(body) + .subarray(0, maxBodySize - 3) + .toString('utf-8')}...` + : body; + + isolationScope.setSDKProcessingMetadata({ normalizedRequest: { data: truncatedBody } }); } } catch (error) { if (DEBUG_BUILD) { diff --git a/packages/node/src/integrations/http/index.ts b/packages/node/src/integrations/http/index.ts index 4a0d3b0d00a4..72326e21e6f1 100644 --- a/packages/node/src/integrations/http/index.ts +++ b/packages/node/src/integrations/http/index.ts @@ -90,6 +90,22 @@ interface HttpOptions { */ ignoreIncomingRequestBody?: (url: string, request: RequestOptions) => boolean; + /** + * Controls the maximum size of incoming HTTP request bodies attached to events. + * + * Available options: + * - 'none': No request bodies will be attached + * - 'small': Request bodies up to 1,000 bytes will be attached + * - 'medium': Request bodies up to 10,000 bytes will be attached (default) + * - 'always': Request bodies will always be attached + * + * Note that even with 'always' setting, bodies exceeding 1MB will never be attached + * for performance and security reasons. + * + * @default 'medium' + */ + maxIncomingRequestBodySize?: 'none' | 'small' | 'medium' | 'always'; + /** * If true, do not generate spans for incoming requests at all. * This is used by Remix to avoid generating spans for incoming requests, as it generates its own spans. From 6d637057dd443964441aa21e4c481c2f42067af4 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Thu, 15 May 2025 16:30:55 +0200 Subject: [PATCH 10/13] Merge pull request #16296 from getsentry/ab/trpc-middleware-isolation-scope-forking feat(node): Fork isolation scope in tRPC middleware --- packages/core/src/trpc.ts | 4 ++-- packages/core/test/lib/trpc.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/trpc.ts b/packages/core/src/trpc.ts index d41030b22dd6..7e29a69903a1 100644 --- a/packages/core/src/trpc.ts +++ b/packages/core/src/trpc.ts @@ -1,4 +1,4 @@ -import { getClient, withScope } from './currentScopes'; +import { getClient, withIsolationScope } from './currentScopes'; import { captureException } from './exports'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes'; import { startSpanManual } from './tracing'; @@ -76,7 +76,7 @@ export function trpcMiddleware(options: SentryTrpcMiddlewareOptions = {}) { } } - return withScope(scope => { + return withIsolationScope(scope => { scope.setContext('trpc', trpcContext); return startSpanManual( { diff --git a/packages/core/test/lib/trpc.test.ts b/packages/core/test/lib/trpc.test.ts index 9a27e62c38ae..c3eca8cf4954 100644 --- a/packages/core/test/lib/trpc.test.ts +++ b/packages/core/test/lib/trpc.test.ts @@ -26,7 +26,7 @@ describe('trpcMiddleware', () => { setExtra: vi.fn(), }; - const withScope = vi.fn(callback => { + const withIsolationScope = vi.fn(callback => { return callback(mockScope); }); @@ -38,7 +38,7 @@ describe('trpcMiddleware', () => { client.init(); vi.spyOn(currentScopes, 'getClient').mockReturnValue(mockClient); vi.spyOn(tracing, 'startSpanManual').mockImplementation((name, callback) => callback(mockSpan, () => {})); - vi.spyOn(currentScopes, 'withScope').mockImplementation(withScope); + vi.spyOn(currentScopes, 'withIsolationScope').mockImplementation(withIsolationScope); vi.spyOn(exports, 'captureException').mockImplementation(() => 'mock-event-id'); }); From c3a9682cdeeda275010ce3539a641641c255f4e8 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 16 May 2025 10:23:55 +0200 Subject: [PATCH 11/13] feat(core): Add `orgId` option to `init` and DSC (`sentry-org_id` in baggage) (#16305) Adds the organization ID to the DSC (dynamic sampling context). This will add the org ID as `sentry-org_id` to the baggage. This org ID is parsed from the DSN. With the `orgId` option it's possible to overwrite the automatically parsed organization ID. closes https://github.com/getsentry/sentry-javascript/issues/16290 --- .size-limit.js | 2 +- .../server-no-explicit-org-id.ts | 34 +++++ .../baggage-org-id/server-no-org-id.ts | 34 +++++ .../baggage-org-id/server.ts | 35 +++++ .../baggage-org-id/test.ts | 37 +++++ .../tracing/browserTracingIntegration.test.ts | 3 + .../src/tracing/dynamicSamplingContext.ts | 11 +- packages/core/src/types-hoist/envelope.ts | 1 + packages/core/src/types-hoist/options.ts | 8 ++ packages/core/src/utils-hoist/dsn.ts | 15 ++ .../tracing/dynamicSamplingContext.test.ts | 132 +++++++++++++++++- packages/core/test/utils-hoist/dsn.test.ts | 36 ++++- 12 files changed, 343 insertions(+), 5 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server-no-explicit-org-id.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server-no-org-id.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/test.ts diff --git a/.size-limit.js b/.size-limit.js index 7721304d684a..012fea839bda 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -8,7 +8,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init'), gzip: true, - limit: '24 KB', + limit: '25 KB', }, { name: '@sentry/browser - with treeshaking flags', diff --git a/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server-no-explicit-org-id.ts b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server-no-explicit-org-id.ts new file mode 100644 index 000000000000..1b3afae252d7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server-no-explicit-org-id.ts @@ -0,0 +1,34 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; + +export type TestAPIResponse = { test_data: { host: string; 'sentry-trace': string; baggage: string } }; + +Sentry.init({ + dsn: 'https://public@o01234987.ingest.sentry.io/1337', + release: '1.0', + environment: 'prod', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +import cors from 'cors'; +import express from 'express'; +import * as http from 'http'; + +const app = express(); + +app.use(cors()); + +app.get('/test/express', (_req, res) => { + const headers = http + .get({ + hostname: 'example.com', + }) + .getHeaders(); + + res.send({ test_data: headers }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server-no-org-id.ts b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server-no-org-id.ts new file mode 100644 index 000000000000..5fe73e5451a9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server-no-org-id.ts @@ -0,0 +1,34 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; + +export type TestAPIResponse = { test_data: { host: string; 'sentry-trace': string; baggage: string } }; + +Sentry.init({ + dsn: 'https://public@public.ingest.sentry.io/1337', + release: '1.0', + environment: 'prod', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +import cors from 'cors'; +import express from 'express'; +import * as http from 'http'; + +const app = express(); + +app.use(cors()); + +app.get('/test/express', (_req, res) => { + const headers = http + .get({ + hostname: 'example.com', + }) + .getHeaders(); + + res.send({ test_data: headers }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server.ts b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server.ts new file mode 100644 index 000000000000..a149f74370f6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server.ts @@ -0,0 +1,35 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; + +export type TestAPIResponse = { test_data: { host: string; 'sentry-trace': string; baggage: string } }; + +Sentry.init({ + dsn: 'https://public@o0000987.ingest.sentry.io/1337', + release: '1.0', + environment: 'prod', + orgId: '01234987', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +import cors from 'cors'; +import express from 'express'; +import * as http from 'http'; + +const app = express(); + +app.use(cors()); + +app.get('/test/express', (_req, res) => { + const headers = http + .get({ + hostname: 'example.com', + }) + .getHeaders(); + + res.send({ test_data: headers }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/test.ts b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/test.ts new file mode 100644 index 000000000000..732473ac4880 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/test.ts @@ -0,0 +1,37 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; +import type { TestAPIResponse } from './server'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should include explicitly set org_id in the baggage header', async () => { + const runner = createRunner(__dirname, 'server.ts').start(); + + const response = await runner.makeRequest('get', '/test/express'); + expect(response).toBeDefined(); + + const baggage = response?.test_data.baggage; + expect(baggage).toContain('sentry-org_id=01234987'); +}); + +test('should extract org_id from DSN host when not explicitly set', async () => { + const runner = createRunner(__dirname, 'server-no-explicit-org-id.ts').start(); + + const response = await runner.makeRequest('get', '/test/express'); + expect(response).toBeDefined(); + + const baggage = response?.test_data.baggage; + expect(baggage).toContain('sentry-org_id=01234987'); +}); + +test('should set undefined org_id when it cannot be extracted', async () => { + const runner = createRunner(__dirname, 'server-no-org-id.ts').start(); + + const response = await runner.makeRequest('get', '/test/express'); + expect(response).toBeDefined(); + + const baggage = response?.test_data.baggage; + expect(baggage).not.toContain('sentry-org_id'); +}); diff --git a/packages/browser/test/tracing/browserTracingIntegration.test.ts b/packages/browser/test/tracing/browserTracingIntegration.test.ts index 728bee5fd1dd..b3686272d12e 100644 --- a/packages/browser/test/tracing/browserTracingIntegration.test.ts +++ b/packages/browser/test/tracing/browserTracingIntegration.test.ts @@ -732,6 +732,7 @@ describe('browserTracingIntegration', () => { sampleRand: expect.any(Number), dsc: { release: undefined, + org_id: undefined, environment: 'production', public_key: 'examplePublicKey', sample_rate: '1', @@ -773,6 +774,7 @@ describe('browserTracingIntegration', () => { sampleRand: expect.any(Number), dsc: { release: undefined, + org_id: undefined, environment: 'production', public_key: 'examplePublicKey', sample_rate: '0', @@ -898,6 +900,7 @@ describe('browserTracingIntegration', () => { expect(dynamicSamplingContext).toBeDefined(); expect(dynamicSamplingContext).toStrictEqual({ release: undefined, + org_id: undefined, environment: 'production', public_key: 'examplePublicKey', sample_rate: '1', diff --git a/packages/core/src/tracing/dynamicSamplingContext.ts b/packages/core/src/tracing/dynamicSamplingContext.ts index 9380c75dd3be..5f10f11db19c 100644 --- a/packages/core/src/tracing/dynamicSamplingContext.ts +++ b/packages/core/src/tracing/dynamicSamplingContext.ts @@ -15,6 +15,7 @@ import { baggageHeaderToDynamicSamplingContext, dynamicSamplingContextToSentryBaggageHeader, } from '../utils-hoist/baggage'; +import { extractOrgIdFromDsnHost } from '../utils-hoist/dsn'; import { addNonEnumerableProperty } from '../utils-hoist/object'; import { getCapturedScopesOnSpan } from './utils'; @@ -44,7 +45,14 @@ export function freezeDscOnSpan(span: Span, dsc: Partial export function getDynamicSamplingContextFromClient(trace_id: string, client: Client): DynamicSamplingContext { const options = client.getOptions(); - const { publicKey: public_key } = client.getDsn() || {}; + const { publicKey: public_key, host } = client.getDsn() || {}; + + let org_id: string | undefined; + if (options.orgId) { + org_id = String(options.orgId); + } else if (host) { + org_id = extractOrgIdFromDsnHost(host); + } // Instead of conditionally adding non-undefined values, we add them and then remove them if needed // otherwise, the order of baggage entries changes, which "breaks" a bunch of tests etc. @@ -53,6 +61,7 @@ export function getDynamicSamplingContextFromClient(trace_id: string, client: Cl release: options.release, public_key, trace_id, + org_id, }; client.emit('createDsc', dsc); diff --git a/packages/core/src/types-hoist/envelope.ts b/packages/core/src/types-hoist/envelope.ts index d874a4e65800..58671c1eba70 100644 --- a/packages/core/src/types-hoist/envelope.ts +++ b/packages/core/src/types-hoist/envelope.ts @@ -25,6 +25,7 @@ export type DynamicSamplingContext = { replay_id?: string; sampled?: string; sample_rand?: string; + org_id?: string; }; // https://github.com/getsentry/relay/blob/311b237cd4471042352fa45e7a0824b8995f216f/relay-server/src/envelope.rs#L154 diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index 4b0010f2b7d7..09dab550be4c 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -320,6 +320,14 @@ export interface ClientOptions { expect(dynamicSamplingContext).toStrictEqual({ public_key: undefined, + org_id: undefined, release: '1.0.1', environment: 'production', sampled: 'true', @@ -91,6 +93,7 @@ describe('getDynamicSamplingContextFromSpan', () => { expect(dynamicSamplingContext).toStrictEqual({ public_key: undefined, + org_id: undefined, release: '1.0.1', environment: 'production', sampled: 'true', @@ -115,6 +118,7 @@ describe('getDynamicSamplingContextFromSpan', () => { expect(dynamicSamplingContext).toStrictEqual({ public_key: undefined, + org_id: undefined, release: '1.0.1', environment: 'production', sampled: 'true', @@ -171,6 +175,7 @@ describe('getDynamicSamplingContextFromSpan', () => { expect(dynamicSamplingContext).toStrictEqual({ public_key: undefined, + org_id: undefined, release: '1.0.1', environment: 'production', trace_id: expect.stringMatching(/^[a-f0-9]{32}$/), @@ -178,3 +183,128 @@ describe('getDynamicSamplingContextFromSpan', () => { }); }); }); + +describe('getDynamicSamplingContextFromClient', () => { + const TRACE_ID = '4b25bc58f14243d8b208d1e22a054164'; + let client: TestClient; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('creates DSC with basic client information', () => { + client = new TestClient( + getDefaultTestClientOptions({ + release: '1.0.0', + environment: 'test-env', + dsn: 'https://public@sentry.example.com/1', + }), + ); + + const dsc = getDynamicSamplingContextFromClient(TRACE_ID, client); + + expect(dsc).toEqual({ + trace_id: TRACE_ID, + release: '1.0.0', + environment: 'test-env', + public_key: 'public', + org_id: undefined, + }); + }); + + it('uses DEFAULT_ENVIRONMENT when environment is not specified', () => { + client = new TestClient( + getDefaultTestClientOptions({ + release: '1.0.0', + dsn: 'https://public@sentry.example.com/1', + }), + ); + + const dsc = getDynamicSamplingContextFromClient(TRACE_ID, client); + + expect(dsc.environment).toBe(DEFAULT_ENVIRONMENT); + }); + + it('uses orgId from options when specified', () => { + client = new TestClient( + getDefaultTestClientOptions({ + orgId: '00222111', + dsn: 'https://public@sentry.example.com/1', + }), + ); + + const dsc = getDynamicSamplingContextFromClient(TRACE_ID, client); + + expect(dsc.org_id).toBe('00222111'); + }); + + it('infers orgId from DSN host when not explicitly provided', () => { + client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://public@o123456.sentry.io/1', + }), + ); + + const dsc = getDynamicSamplingContextFromClient(TRACE_ID, client); + + expect(dsc.org_id).toBe('123456'); + }); + + it('prioritizes explicit orgId over inferred from DSN', () => { + client = new TestClient( + getDefaultTestClientOptions({ + orgId: '1234560', + dsn: 'https://public@my-org.sentry.io/1', + }), + ); + + const dsc = getDynamicSamplingContextFromClient(TRACE_ID, client); + + expect(dsc.org_id).toBe('1234560'); + }); + + it('handles orgId passed as number', () => { + client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://public@my-org.sentry.io/1', + orgId: 123456, + }), + ); + + const dsc = getDynamicSamplingContextFromClient(TRACE_ID, client); + + expect(dsc.org_id).toBe('123456'); + }); + + it('handles missing DSN gracefully', () => { + client = new TestClient( + getDefaultTestClientOptions({ + release: '1.0.0', + }), + ); + + const dsc = getDynamicSamplingContextFromClient(TRACE_ID, client); + + expect(dsc.public_key).toBeUndefined(); + expect(dsc.org_id).toBeUndefined(); + }); + + it('emits createDsc event with the generated DSC', () => { + client = new TestClient( + getDefaultTestClientOptions({ + release: '1.0.0', + dsn: 'https://public@sentry.example.com/1', + }), + ); + + const emitSpy = vi.spyOn(client, 'emit'); + + const dsc = getDynamicSamplingContextFromClient(TRACE_ID, client); + + expect(emitSpy).toHaveBeenCalledWith('createDsc', dsc); + }); +}); diff --git a/packages/core/test/utils-hoist/dsn.test.ts b/packages/core/test/utils-hoist/dsn.test.ts index 6d34b599c6c9..b5d22130816b 100644 --- a/packages/core/test/utils-hoist/dsn.test.ts +++ b/packages/core/test/utils-hoist/dsn.test.ts @@ -1,6 +1,6 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { beforeEach, describe, expect, it, test, vi } from 'vitest'; import { DEBUG_BUILD } from '../../src/debug-build'; -import { dsnToString, makeDsn } from '../../src/utils-hoist/dsn'; +import { dsnToString, extractOrgIdFromDsnHost, makeDsn } from '../../src/utils-hoist/dsn'; import { logger } from '../../src/utils-hoist/logger'; function testIf(condition: boolean) { @@ -215,3 +215,35 @@ describe('Dsn', () => { }); }); }); + +describe('extractOrgIdFromDsnHost', () => { + it('extracts the org ID from a DSN host with standard format', () => { + expect(extractOrgIdFromDsnHost('o123456.sentry.io')).toBe('123456'); + }); + + it('extracts numeric org IDs of different lengths', () => { + expect(extractOrgIdFromDsnHost('o1.ingest.sentry.io')).toBe('1'); + expect(extractOrgIdFromDsnHost('o42.sentry.io')).toBe('42'); + expect(extractOrgIdFromDsnHost('o9999999.sentry.io')).toBe('9999999'); + }); + + it('returns undefined for hosts without an org ID prefix', () => { + expect(extractOrgIdFromDsnHost('sentry.io')).toBeUndefined(); + expect(extractOrgIdFromDsnHost('example.com')).toBeUndefined(); + }); + + it('returns undefined for hosts with invalid org ID format', () => { + expect(extractOrgIdFromDsnHost('oabc.sentry.io')).toBeUndefined(); + expect(extractOrgIdFromDsnHost('o.sentry.io')).toBeUndefined(); + expect(extractOrgIdFromDsnHost('oX123.sentry.io')).toBeUndefined(); + }); + + it('handles different domain variations', () => { + expect(extractOrgIdFromDsnHost('o123456.ingest.sentry.io')).toBe('123456'); + expect(extractOrgIdFromDsnHost('o123456.custom-domain.com')).toBe('123456'); + }); + + it('handles empty string input', () => { + expect(extractOrgIdFromDsnHost('')).toBeUndefined(); + }); +}); From bad34c82824e64e3d10ddabe265bbb40b4e5fdcc Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Mon, 19 May 2025 12:55:25 +0200 Subject: [PATCH 12/13] chore(node): Remove unused `stealthWrap` and utils around it (#16325) The utilities around wrapping are no longer in use since the switch to diagnostics channels in #16177. --- packages/node/src/integrations/http/utils.ts | 39 -------------------- 1 file changed, 39 deletions(-) delete mode 100644 packages/node/src/integrations/http/utils.ts diff --git a/packages/node/src/integrations/http/utils.ts b/packages/node/src/integrations/http/utils.ts deleted file mode 100644 index ddb803c8fc58..000000000000 --- a/packages/node/src/integrations/http/utils.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * This is a minimal version of `wrap` from shimmer: - * https://github.com/othiym23/shimmer/blob/master/index.js - * - * In contrast to the original implementation, this version does not allow to unwrap, - * and does not make it clear that the method is wrapped. - * This is necessary because we want to wrap the http module with our own code, - * while still allowing to use the HttpInstrumentation from OTEL. - * - * Without this, if we'd just use `wrap` from shimmer, the OTEL instrumentation would remove our wrapping, - * because it only allows any module to be wrapped a single time. - */ -export function stealthWrap( - nodule: Nodule, - name: FieldName, - wrapper: (original: Nodule[FieldName]) => Nodule[FieldName], -): Nodule[FieldName] { - const original = nodule[name]; - const wrapped = wrapper(original); - - defineProperty(nodule, name, wrapped); - return wrapped; -} - -// Sets a property on an object, preserving its enumerability. -function defineProperty( - obj: Nodule, - name: FieldName, - value: Nodule[FieldName], -): void { - const enumerable = !!obj[name] && Object.prototype.propertyIsEnumerable.call(obj, name); - - Object.defineProperty(obj, name, { - configurable: true, - enumerable: enumerable, - writable: true, - value: value, - }); -} From 6da8816b2d81e95d3c2b6d8b7b6c675d2d0ab464 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 15 May 2025 16:50:22 +0200 Subject: [PATCH 13/13] meta(changelog): Update changelog for 9.20.0 --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78064f48920a..ebfcb504f813 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,26 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 9.20.0 + +### Important changes + +- **feat(browser): Track measure detail as span attributes ([#16240](https://github.com/getsentry/sentry-javascript/pull/16240))** + +The SDK now automatically collects details passed to `performance.measure` options. + +### Other changes + +- feat(node): Add `maxIncomingRequestBodySize` ([#16225](https://github.com/getsentry/sentry-javascript/pull/16225)) +- feat(react-router): Add server action instrumentation ([#16292](https://github.com/getsentry/sentry-javascript/pull/16292)) +- feat(react-router): Filter manifest requests ([#16294](https://github.com/getsentry/sentry-javascript/pull/16294)) +- feat(replay): Extend default list for masking with `aria-label` ([#16192](https://github.com/getsentry/sentry-javascript/pull/16192)) +- fix(browser): Ensure pageload & navigation spans have correct data ([#16279](https://github.com/getsentry/sentry-javascript/pull/16279)) +- fix(cloudflare): Account for static fields in wrapper type ([#16303](https://github.com/getsentry/sentry-javascript/pull/16303)) +- fix(nextjs): Preserve `next.route` attribute on root spans ([#16297](https://github.com/getsentry/sentry-javascript/pull/16297)) +- feat(node): Fork isolation scope in tRPC middleware ([#16296](https://github.com/getsentry/sentry-javascript/pull/16296)) +- feat(core): Add `orgId` option to `init` and DSC (`sentry-org_id` in baggage) ([#16305](https://github.com/getsentry/sentry-javascript/pull/16305)) + ## 9.19.0 - feat(react-router): Add otel instrumentation for server requests ([#16147](https://github.com/getsentry/sentry-javascript/pull/16147))