diff --git a/.size-limit.js b/.size-limit.js index 0c03c0ff1b8b..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', @@ -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/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)) 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/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/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/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/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/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/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/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/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-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-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, 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 { 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/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(); 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/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 5f791a75e614..f0e2a6bb374a 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -336,6 +336,8 @@ export class SentrySpan implements Span { const { scope: capturedSpanScope, isolationScope: capturedSpanIsolationScope } = getCapturedScopesOnSpan(this); + const normalizedRequest = capturedSpanScope?.getScopeData().sdkProcessingMetadata?.normalizedRequest; + if (this._sampled !== true) { return undefined; } @@ -374,6 +376,7 @@ export class SentrySpan implements Span { capturedSpanIsolationScope, dynamicSamplingContext: getDynamicSamplingContextFromSpan(this), }, + request: normalizedRequest, ...(source && { transaction_info: { source, 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/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/instrument.ts b/packages/core/src/types-hoist/instrument.ts index 5eba6066432a..b067f6618558 100644 --- a/packages/core/src/types-hoist/instrument.ts +++ b/packages/core/src/types-hoist/instrument.ts @@ -78,7 +78,9 @@ export interface HandlerDataConsole { } export interface HandlerDataHistory { + /** The full URL of the previous page */ from: string | undefined; + /** The full URL of the new page */ to: string; } 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/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'); }); 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(); + }); +}); 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 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. 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, - }); -} 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) 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 b0ca0e79bd49..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. @@ -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 ) { 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'); 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 = [],